WebStorm现在免费啦!
前言
就在昨天1024程序员节,JetBrains突然宣布WebStorm现在对非商业用途
免费啦。以后大家再也不用费尽心思的去找破解方法了,并且公告中的关于非商业用途
定义也很有意思。
加入欧阳的高质量vue源码交流群、欧阳平时写文章参考的多本vue源码电子书
为什么免费
在公告中的原话是:
我们希望新的许可模式将进一步降低使用 IDE 的门槛,帮助大家学习、成长并保持创造力。
欧阳个人还是觉得现在大家都在使用vscode
或者Cursor
。
如果我不想付费,那么我会选择开源的vscode,安装上插件后体验完全不输于WebStorm。
如果我想付费获得更好的体验,那么为什么不使用AI加持的Cursor
呢?
前段时间网上有很多吸引人眼球的段子,比如:
我6岁的儿子使用
Cursor
开发了一个个人网站
又或者:
我是一名UI设计师,使用
Cursor
轻松的开发了一个APP
欧阳也一直在使用Cursor
,虽然没有网上那些段子那样把Cursor
吹的那么神。但是对于开发来说Cursor
是真的很好用,经常觉得Cursor
比我更知道我接下来应该写什么代码。如果我选择付费,为什么不考虑更加好用的Cursor
呢?
不管是免费还是付费市场,vscode系
的IDE已经占据了很大的比例。欧阳个人觉得WebStorm
为了能够重新占据市场,所以选择推出非商业用途免费
的WebStorm
非商业和商业有什么区别
非商业和商业的WebStorm区别只有一个,Code With Me
功能。如果是非商业的WebStorm里面的Code With Me
是社区版。
Code With Me
是一个协作工具,允许多个开发者实时共享代码和协作编程。通过这项功能,用户可以在WebStorm、Rider等IDE中与他人共同编辑代码、进行调试和解决问题。
这个是Code With Me
社区版和非社区版的区别:
如何判断是否是非商业
公告中的原文是:
商业产品是指有偿分发或提供、或者作为您的商业活动的一部分使用的产品。 但某些类别被明确排除在这一定义之外。 常见的非商业用例包括学习和自我教育、任何形式的内容创作、开源代码和业余爱好开发。
这不就是完全靠自觉吗?不需要证明我是用于非商业,欧阳觉得这是故意为之。
小公司使用Webstorm的非商业模式
进行业务开发,人家看不上你,所以懒得搭理你。就像是以前在小公司里面使用破解版本的webstorm开发一样。
但是在公告中明确有写:
如果您使用非商业许可证,将无法选择退出匿名使用情况统计信息收集。 我们使用此类信息改进我们的产品。 这一规定与我们的抢先体验计划 (EAP) 类似,并符合我们的隐私政策。
意思是如果你使用了非商业
版本,那么JetBrains就能拿到你的数据。
如果在大公司里面使用非商业模式
进行业务开发,那么Webstorm
在拿到数据的情况下就是一告一个准。就像是大公司里面禁止使用破解版本的webstorm开发业务一样,欧阳个人觉得有点像是钓鱼。
如何使用非商业版
使用方式很简单,首先从官网下载安装包。然后在对话框中选择非商业模式
,如下图:
接着勾选协议,点击开始非商业使用,如下图:
此时会在浏览器中新开页面让你登录,登录方式有很多种:比如谷歌、GitHub、微信等。这里欧阳选择的是谷歌登录,如下图:
最后就成功啦,非商业有效期是一年,一年后会自动续订。
总结
WebStorm推出的非商业版免费对于开发者来说肯定是好事,特别是一些使用WebStorm的独立开发,还有小公司里面的开发,但是大公司里面想使用非商业版就需要三思了。
来源:juejin.cn/post/7429381641700048923
程序员攻占小猿口算,炸哭小学生!
小学生万万没想到,做个加减乘除的口算练习题,都能被大学生、博士生、甚至是程序员大佬们暴打!
最近这款拥有 PK 功能的《小猿口算》App 火了,谁能想到,本来一个很简单的小学生答题 PK,竟然演变为了第四次忍界大战!
刚开始还是小学生友好 PK,后面突然涌入一波大学生来踢馆,被网友称为 “大学生炸鱼”;随着战况愈演愈烈,硕士生和博士生也加入了战场,直接把小学生学习软件玩成了电子竞技游戏,谁说大一就不是一年级了?这很符合当代大学生的精神状态。
然而,突然一股神秘力量出现,是程序员带着科技加入战场! 自动答题一秒一道 ,让小学生彻底放弃,家长们也无可奈何,只能在 APP 下控诉严查外挂。
此时很多人还没有意识到,小学生口算 PK,已经演变为各大高校和程序员之间的算法学术交流竞赛!
各路大神连夜改进算法,排行榜上的数据也是越发离谱,甚至卷到了 0.1 秒一道题!
算法的演示效果,可以看我发的 B 站视频。
接口也是口,算法也是算,这话没毛病。
这时,官方不得不出手来保护小学生了,战况演变为官方和广大程序员的博弈。短短几天,GitHub 上开源的口算脚本就有好几页,程序员大神们还找到了多种秒速答题的方案。
官方刚搞了加密,程序员网友马上就成功解密,以至于 网传 官方不得不高价招募反爬算法工程师,我建议直接把这些开源大佬招进去算了。
实现方法
事情经过就是这样,我相信朋友们也很好奇秒答题目背后的实现原理吧,这里我以 GitHub 排名最高的几个脚本项目为例,分享 4 种实现方法。当然,为了给小学生更好的学习体验,这里我就不演示具体的操作方法了,反正很快也会被官方打压下去。
方法 1、OCR 识别 + 模拟操作
首先使用模拟器在电脑上运行 App,运用 Python 读取界面上特定位置的题目,然后运用 OCR 识别技术将题目图片识别为文本并输入给算法程序来答题,最后利用 Python 的 pyautogui 库来模拟人工点击和输入答案。
这种方法比较好理解,应用范围也最广,但缺点是识别效果有限,如果题目复杂一些,准确度就不好保证了。
详见开源仓库:github.com/ChaosJulien…
方法 2、抓包获取题目和答案
通过 Python 脚本抓取 App 的网络请求包,从中获取题目和答案,然后通过 ADB(Android Debug Bridge)模拟滑动操作来自动填写答案。然而,随着官方升级接口并加密数据,这种方法已经失效。
详见开源仓库:github.com/cr4n5/XiaoY…
方法 3、抓包 + 修改答案
这个方法非常暴力!首先通过抓包工具拦截口算 App 获取题目数据和答案的网络请求,然后修改请求体中的答案全部为 “1”,这样就可以通过 ADB 模拟操作,每次都输入 1 就能快速完成答题。 根据测试可以达到接近 0 秒的答题时间!
但是这个方法只对练习场有效,估计是练习场的答题逻辑比较简单,且没有像 PK 场那样的复杂校验。
详见开源仓库:github.com/cr4n5/XiaoY…
方法 4、修改 PK 场的 JavaScript 文件
这种方法就更暴力了!在 PK 场模式下,修改 App 内部的 JavaScript 文件来更改答题逻辑。通过分析 JavaScript 响应中的 isRight
函数,找到用于判定答案正确与否的逻辑,然后将其替换为 true,强制所有答案都判定为正确,然后疯狂点点点就行了。
详见开源仓库:github.com/cr4n5/XiaoY…
能这么做是因为 App 在开发时采用了混合 App 架构,一些功能是使用 WebView 来加载网页内容的。而且由于 PK 场答题逻辑是在前端进行验证,而非所有请求都发送到服务器进行校验,才能通过直接修改前端 JS 文件绕过题目验证。
官方反制
官方为了保护小学生学习的体验,也是煞费苦心。
首先加强了用户身份验证和管理,防止大学生炸鱼小学生;并且为了照顾大学生朋友,还开了个 “巅峰对决” 模式,让俺们也可以同实力竞技 PK。
我建议再增加一个程序员模式,也给爱玩算法的程序员一个竞技机会。
其实从技术的角度,要打击上述的答题脚本,并不难。比如检测 App 运行环境,发现是模拟器就限制答题;通过改变题目的显示方式来对抗 OCR 识别;通过随机展示部分 UI, 让脚本无法轻易通过硬编码的坐标点击正确的答案;还可以通过分析用户的答题速度和操作模式来识别脚本,比如答题速度快于 0.1 秒的用户,显然已经超越了人类的极限。
0.0 秒的这位朋友,是不是有点过分(强大)了?
但最关键的一点是,目前 App 的判题逻辑是在前端负责处理的,意味着题目答案的验证可以在本地进行,而不必与服务器通信,这就给了攻击者修改前端文件的机会。虽然官方通过接口加密和行为分析等手段加强了防御,但治标不治本,还是将判题逻辑转移到服务端,会更可靠。
当然,业务流程改起来哪有那么快呢?
不过现在的局面也不错,大学生朋友快乐了,程序员玩爽了,口算 App 流量赢麻了,可谓是皆大欢喜!
等等,好像有哪里不对。。。别再欺负我们的小学生啦!
更多
来源:juejin.cn/post/7425121392738140214
技术大佬 问我 订单消息乱序了怎么办?
技术大佬 :佩琪,最近看你闷闷不乐了,又被虐了?
佩琪:(⊙o⊙)…,又被大佬发现了。这不最近出去面试都揉捏的像一个麻花了嘛
技术大佬 :哦,这次又是遇到什么难题了?
佩琪: 由于和大佬讨论过消息不丢,消息防重等技能(见
kafka 消息“零丢失”的配方 和技术大佬问我 订单消息重复消费了 怎么办?
),所以在简历的技术栈里就夸大似的写了精通kafka消息中间件,然后就被面试官炮轰了里面的细节
佩琪: 其中面试官给我印象深刻的一个问题是:你们的kafka消息里会有乱序消费的情况吗?如果有,是怎么解决的了?
技术大佬 :哦,那你是怎么回答的了?
佩琪:我就是个crud boy,根本不知道啥是顺序消费啥是乱序消费,所以就回答说,没有
技术大佬 :哦,真是个诚实的孩子;然后呢?
佩琪:然后面试官就让我回家等通知了,然后就没有然后了。。。。
佩琪 : 对了大佬,什么是消息乱序消费了?
技术大佬 :消息乱序消费,一般指我们消费者应用程序不按照,上游系统 业务发生的顺序,进行了业务消息的颠倒处理,最终导致消费业务出错。
佩琪 :低声咕噜了下你这说的是人话吗?大声问答:这对我的小脑袋有点抽象了,大佬能举个实际的栗子吗?
技术大佬 :举个上次我们做的促销数据同步的栗子吧,大概流程如下:
技术大佬 :上次我们做的促销业务,需要在我们的运营端后台,录入促销消息;然后利用kafka同步给三方业务。在业务流程上,是先新增促销信息,然后可能删除促销信息;但是三方消费端业务接受到的kafka消息,可能是先接受到删除促销消息;随后接受到新增促销消息;这样不就导致了消费端系统和我们系统的促销数据不一致了嘛。所以你是消费方,你就准备接锅吧,你不背锅,谁背锅了?
佩琪 :-_-||,此时佩琪心想,锅只能背一次,坑只能掉一次。赶紧问到:请问大佬,消息乱序了后,有什么解决方法吗?
技术大佬 : 此时抬了抬眼睛,清了清嗓子,面露自信的微笑回答道。一般都是使用顺序生产,顺序存储,顺序消费的思想来解决。
佩琪 :摸了摸头,能具体说说,顺序生产,顺序存储,顺序消费吗?
技术大佬 : 比如kafka,一般建议同一个业务属性数据,都往一个分区上发送;而kafka的一个分区只能被一个消费者实例消费,不能被多个消费者实例消费。
技术大佬 : 也就是说在生产端如果能保证 把一个业务属性的消息按顺序放入同一个分区;那么kakfa中间件的broker也是顺序存储,顺序给到消费者的。而kafka的一个分区只能被一个消费者消费;也就不存在多线程并发消费导致的顺序问题了。
技术大佬 :比如上面的同步促销消息;不就是两个消费者,拉取了不同分区上的数据,导致消息乱序处理,最终数据不一致。同一个促销数据,都往一个分区上发送,就不会存在这样的乱序问题了。
佩琪 :哦哦,原来是这样,我感觉这方案心理没底了,大佬能具体说说这种方案有什么优缺点吗?
技术大佬 :给你一张图,你学习下?
优点 | 缺点 |
---|---|
生产端实现简单:比如kafka 生产端,提供了按指定key,发送到固定分区的策略 | 上游难保证严格顺序生产:生产端对同一类业务数据需要按照顺序放入同一个分区;这个在应用层还是比较的难保证,毕竟上游应用都是无状态多实例,多机器部署,存在并发情况下执行的先后顺序不可控 |
消费端实现也简单 :kafka消费者 默认就是单线程执行;不需要为了顺序消费而进行代码改造 | 消费者处理性能会有潜在的瓶颈:消费者端单线程消费,只能扩展消费者应用实例来进行消费者处理能力的提升;在消息较多的时候,会是个处理瓶颈,毕竟干活的进程上限是topic的分区数。 |
无其它中间件依赖 | 使用场景有取限制:业务数据只能指定到同一个topic,针对某些业务属性是一类数据,但发送到不同topic场景下,则不适用了。比如订单支付消息,和订单退款消息是两个topic,但是对于下游算佣业务来说都是同一个订单业务数据 |
佩琪 :大佬想偷懒了,能给一个 kafka 指定 发送到固定分区的代码吗?
技术大佬 :有的,只需要一行代码,你要不自己动手尝试下?
KafkaProducer.send(new ProducerRecord[String,String](topic,key,msg),new Callback(){} )
topic:主题,这个玩消息的都知道,不解释了
key: 这个是指定发送到固定分区的关键。一般填写订单号,或者促销ID。kafka在计算消息该发往那个分区时,会默认使用hash算法,把相同的key,发送到固定的分区上
msg: 具体消息内容
佩琪 :大佬,我突然记起,上次我们做的 订单算佣业务了,也是利用kafka监听订单数据变化,但是为什么没有使用固定分区方案了?
技术大佬 : 主要是我们上游业务方:把订单支付消息,和订单退款消息拆分为了两个topic,这个从使用固定分区方案的前提里就否定了,我们不能使用此方案。
佩琪 :哦哦,那我们是怎么去解决这个乱序的问题的了?
技术大佬 :主要是根据自身业务实际特性;使用了数据库乐观锁的思想,解决先发后至,后发先至这种数据乱序问题。
大概的流程如下图:
佩琪 :摸了摸头,大佬这个自身业务的特性是啥了?
技术大佬 :我们算佣业务,主要关注订单的两个状态,一个是订单支付状态,一个是订单退款状态。
订单退款发生时间肯定是在订单支付后;而上游订单业务是能保证这两个业务在时间发生上的前后顺序的,即订单的支付时间,肯定是早于订单退款时间。所以主要是利用订单ID+订单更新时间戳,做为数据库佣金表的更新条件,进行数据的乱序处理。
佩琪 : 哦哦,能详细说说 这个数据库乐观锁是怎么解决这个乱序问题吗?
技术大佬 : 比如:当佣金表里订单数据更新时间大于更新条件时间 就放弃本次更新,表明消息数据是个老数据;即查询时不加锁;
技术大佬 :而小于更新条件时间的,表明是个订单新数据,进行数据更新。即在更新时 利用数据库的行锁,来保证并发更新时的情况。即真实发生修改时加锁。
佩琪 :哦哦,明白了。原来一条带条件更新的sql,就具备了乐观锁思想。
技术大佬 :我们算佣业务其实是只关注佣金的最终状态,不关注中间状态;所以能用这种方式,保证算佣数据的最终一致性,而不用太关注订单的中间状态变化,导致佣金的中间变化。
总结
要想保证消息顺序消费大概有两种方案
固定分区方案
1、生产端指定同一类业务消息,往同一个分区发送。比如指定发送key为订单号,这样同一个订单号的消息,都会发送同一个分区
2、消费端单线程进行消费
乐观锁实现方案
如果上游不能保证生产的顺序;可让上游加上数据更新时间;利用唯一ID+数据更新时间,+乐观锁思想,保证业务数据处理的最终一致性。
原创不易,请 点赞,留言,关注,收藏 4暴击^^
天冷了,多年不下雪的北京,下了一场好大的雪。如果暴击不能让您动心,请活动下小手
佩琪正在参与 掘金2023年度人气创作者打榜中,感谢掘友们的支持,为佩琪助助力,也是对我文章输出的鼓励和支持 ~ ~ 万分感谢 activity.juejin.cn/rank/2023/w…
来源:juejin.cn/post/7303833186068086819
冷板凳30年,离职时75岁!看完老爷子的简历,我失眠了
0x01
前几天,科技圈又一个爆款新闻相信不少同学都已经看到了。
那就是77岁的计算机科学家,同时也是一位享誉全球的人工智能专家 Geoffrey Hint0n 和 John J. Hopfield 一起,拿到了2024年诺贝尔物理学奖,以表彰他们通过人工神经网络实现机器学习的奠基性发现和发明。
消息一出,在科技届引起了一阵广泛的关注和讨论,以至于不少同学都发出感叹,“AI法力无边”、“人工智能终于不止是技术,而是科学了”。
而提到 Hint0n 这个名字,对于学习和从事AI人工智能和机器学习等领域的同学来说,应该都非常熟悉了。
Hint0n 是一位享誉全球的人工智能专家,被誉为“神经网络之父”、“深度学习的鼻祖”、“人工智能教父”等等,在这个领域一直以来都是最受尊崇的泰斗之一。
而上一次 Hint0n 站在聚光灯下则是5年前,彼时的 Hint0n 刚拿下2018年度图灵奖。
至此,AI教父 Hint0n 也成为了图灵奖和诺贝尔奖的双料得主!
0x02
大多人都是因为近年大火的AI才了解的Hint0n,但是他之前的人生经历也是相当富有戏剧性的。
1947年,Geoffrey Hint0n出生于英国温布尔登的一个知识分子家庭。
他的父亲Howard Everest Hint0n是一个研究甲壳虫的英国昆虫学家,而母亲Margaret Clark则是一名教师。
除此之外,他的高曾祖父George Boole还是著名的逻辑学家,也是现代计算科学的基础布尔代数的发明人,而他的叔叔Colin Clark则是一个著名的经济学家。
可以看到,Hint0n家庭里的很多成员都在学术和研究方面都颇有造诣。
当然,在这样的氛围下长大的Hint0n,其成长压力也是可想而知的。
1970年,23岁的Hint0n获得了实验心理学的学士学位。但是,令谁也没有想到的是,毕业后这位“学二代”由于找不到科研的意义,他竟然先跑去当了一段时间的木匠。
不过这个经历并没有帮助他消除自己的阶段性迷茫,他一直希望真正理解大脑的运作原理,渴望更深入的理论研究,于是经历过一番思想斗争后又下决心重返学术殿堂,投身于人工智能领域。
直到 1978 年,他终于获得了爱丁堡大学人工智能学博士学位,而此时的他也已经 31 岁了。
那个年代做深度学习的研究可以说是妥妥的冷板凳,因为你要知道当时的AI正值理论萌芽阶段,Hint0n所主张和研究的深度学习派更是不太为关注和认可。
那面对这一系列冷漠甚至反对,唯有纯粹的相信与热爱才能将这个领域深耕了数十年,直到后来 AI 时代的来临。
而这一切,Hint0n 都做到了。
0x03
Hint0n主要从事神经网络和机器学习的研究,在AI领域做出过许多重要贡献,其中最著名的当属他在神经网络领域所做的研究工作。
他在20世纪80年代就已经开启了反向传播算法(Back Propagation, BP算法)的研究,并将其应用于神经网络模型的训练中。这一算法被广泛应用于语音识别、图像识别和自然语言处理等领域。
除此之外,Hint0n还在卷积神经网络(Convolutional Neural Networks,CNN)、深度置信网络(Deep Belief Networks,DBN)、递归神经网络(Recursive Neural Networks,RNN)、胶囊网络(Capsule Network)等领域做出了重要贡献。
2013年,Hint0n加入Google,同时把机器学习相关的很多技术带进了谷歌,同时融合到谷歌的多项业务之中。
2019年3月,ACM公布了2018年度的图灵奖得主。
图灵奖大家都知道,是计算机领域的国际最高奖项,也被誉为“计算机界的诺贝尔奖”。
而Hint0n则与蒙特利尔大学计算机科学教授Yoshua Bengio和Meta首席AI科学家Yann LeCun一起因为研究神经网络而获得了该年度的图灵奖,以表彰他们在对应领域所做的杰出贡献。
除此之外,Hint0n在他的学术生涯中发表了数百篇论文,这些论文中提出了许多重要的理论和方法,涵盖了人工智能、机器学习、神经网络、计算机视觉等多个领域。
而且他的论文被引用的次数也是惊人,这对于这些领域的研究和发展都产生了重要的影响。
0x04
除了自身在机器学习方面的造诣很高,Hint0n同时也是一个优秀的老师。
当年为了扩大深度学习的圈子,Hint0n曾在多伦多大学成立过研究中心,专门接收有兴趣从事相关研究的年轻人,以至于现如今AI圈“半壁江山”都是他的“门生”。
Hint0n带过很多大牛学生,其中不少都被像苹果、Google等这类硅谷科技巨头所挖走,在对应的公司里领导着人工智能相关领域的研究。
这其中最典型的就是Ilya Sutskever,他是Hint0n的学生,同时他也是最近大名鼎鼎的OpenAI公司的联合创始人和首席科学家。
聊到这里,不得不感叹大佬们的创造力以及对这个领域所作出的贡献,同时也期待大佬们后续给大家带来更多精彩的故事。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7425848834456961051
前端自动化部署的极简方案
打开服务器连接,找到文件夹,删掉,找到打包的目录,ctrl + C, ctrl + v 。。。。
烦的要死。。内网开发,node 的 ssh2 依赖库一时半会还导不进来。
索性,自己写一个!
原生 NodeJS 代码,不需要引用任何第三方库 win10 及以上版本,系统自带ssh 命令行工具,如果没有,需要自行安装
首先,需要生成本地秘钥;
ssh-keygen
执行上述命令后,系统会提示你输入文件保存位置和密码,如果你想使用默认位置和密码,直接按回车接受即可。这将在默认的SSH目录~/.ssh/
下生成两个文件:id_rsa
(私钥)和id_rsa.pub
(公钥)
开启服务端使用秘钥登录
一般文件位置位于 /etc/ssh/sshd_config 中,
找到下面两行 ,取消注释,值改为 yes
RSAAuthentication yes
PubkeyAuthentication yes
将秘钥添加到服务端:
打开服务端文件 /root/.ssh/authorized_keys 将公钥 粘贴到新的一行中
重启服务端 ssh 服务
sudo service ssh restart
编写自动化上传脚本(nodejs 脚本)
// 创建文件 ./build/Autoactic.js
const { exec, spawn } = require('child_process');
const fs= require('fs');
// C:/Users/admin/.ssh/server/ServiceOptions.json
// 此处储存本地连接服务端相关配置数据(目的为不将秘钥暴露给 Git 提交代码)
// {
// 服务端关键字(记录为哪个服务器)
// "Test90": {
// 服务器登录用户名
// "Target": "root@255.255.255.255",
// 本地证书位置(秘钥)
// "Pubkey": "C:/User/admin/.shh/server/file"
// }
// }
// 温馨提示 本机储存的秘钥需要调整权限,需要删除除了自己以外其他的全部用户
const ServiceOption = Json.parse(fs.readFileSync("C:/Users/admin/.ssh/server/ServiceOptions.json"), "utf-8");
// 本地项目文件路径(dist 打包后的实际路径)
const LocalPath = "D:/Code/rmgm/jilinres/jprmcrm/dev/admin/dist";
// 服务端项目路径
const ServerPath = "/home/rmgmuser/web/pmr";
// 运行单行命令 (scp 命令,上传文件使用)
const RunSSHCode = function (code) {
return new Promise((resolve, reject) => {
const process = exec(code, (error, sodut, stderr) => {
if (error) {
console.error(`执行错误: ${error}`)
reject();
return;
};
console.log(`sodut:${sodut}`);
if (stderr) {
console.error(`stderr:${stderr}`)
};
if (process && !process.killed){
process.kill();
};
setTimeout(()=>{
resolve();
},10);
})
})
}
// 执行服务端命令 (执行 ssh 命令行)
const CommandHandle(command) {
return new Promise((resolve, reject) => {
const child = spawn('ssh', ['-i', ServiceOption.Test90.Pubkey, '-o', 'StrictHostKeyChecking=no', ServiceOption.Test90.Target], {
stdio: ['pipe']
});
child.on('close',(err)=>{
console.log(`--close--:${err}`);
if (err === 0) {
setTimeout(()=>{ resolve() },10)
} else {
reject();
}
})
child.on('error',(err)=>{
console.error(`--error--:${err}`)
});
console.log(`--command--:${command}`);
child.stdin.end(command);
child.stdout.on('data',(data)=>{
console.log(`Stdout:${data}`);
})
child.stderr.on('data',(data)=>{
console.log(`Stdout:${data}`);
})
}
};
// 按照顺序执行代码
!(async function (CommandHandle, RunSSHCode){
try {
console.log(`创建文件夹 => ${ServerPath}`);
await CommandHandle(`mkdir -p ${ServerPath}`);
console.log(`创建文件夹 => ${ServerPath} => 完成`);
console.log(`删除历史文件 => ${ServerPath}`);
await CommandHandle(`find ${ServerPath} -type d -exec rm -rf {} +`);
console.log(`删除历史文件 => ${ServerPath} => 完成`);
console.log(`上传本地文件 => 从 本地 ${LocalPath} 到 服务端 ${ServerPath}`);
await RunSSHCode(`scp -i ${serviceOption.Test90.pubkey} -r ${LocalPath}/ ${serviceOption.Test90.Target}:${ServerPath}/`);
console.log(`上传本地文件 => 从 本地 ${LocalPath} 到 服务端 ${ServerPath} => 完成`);
// 吉林环境个性配置 非必须(更改访问权限)
console.log(`更改访问权限 => ${ServerPath}`);
await CommandHandle(`chown -R rmgmuser:rmgmuser ${ServerPath}`);
console.log(`更改访问权限 => ${ServerPath} => 完成`);
} catch (error) {
console.error(`---END---`, error)
}
})(CommandHandle, RunSSHCode)
更改打包命令:
// package.json
{
// ....
"scripts": {
// .....
"uploadFile" : "node ./build/Autoactic.js"
// 原始的 build 改为 prebuild 命令
"preBuild" : "vue-cli-service build --mode production"
// npm 按顺序运行多个命令行
"build" : "npm run preBuild && npm run uploadFile"
// .....
}
//...
}
效果 打包结束后,直接上传到服务端。
有特殊需求,例如重启服务等,可自行添加。
来源:juejin.cn/post/7431591478508748811
居然还能这么画骑车线路?:手绘骑行路线 和 起始点途径点规划 导出GPX数据
写在前面
众所周知啊骑行🚲是一项非常健康、锻炼身体素质、拓宽视野的一项运动,在如今的2024年啊,越来越多的小孩年轻人等等各类人群都加入了骑行这项运动,哈哈本人也不例外😲,像今年的在中国举办的环广西更是加深了国内的骑行氛围,那导播的运镜水平相比去年越来越有观赏性。
在骑行过程中,其中一些想记录自己骑行数据的骑友会选择一些子骑行软件啊,比如像行者、Strva、捷安特骑行等等这些子,功能都非常丰富,他们都会有路线规划这个功能,大部分规划的方案我知道的大概分为 起始点规划
、起始+途径点规划
、GPX文件导入
这三个主要功能前二者都是靠输入明确地点来确定路线,对于没有明确骑行目的地、选择困难症的一些朋友想必是一大考验,于是我就在想可不可以在地图上画一个大概的线路来生成地图?答案是可以的!
技术分析
灵感来自高德app中运动的大栏中有一个跑步线路规划这一功能,其中的绘制路线就是我们想要的功能,非常方便在地图上画一个大概的线路,然后自动帮你匹配道路上,但是高德似乎没有道路匹配得API?
但是!他有线路纠偏
这个功能,这个API大概的功能就是把你历史行进过的线路纠偏到线路上,我们可以将画好得线路模拟出一段行驶轨迹,模拟好方向角、时间和速度,就可以了,这就是我们下面要做得手绘线路
这个功能,规划线路那肯定不能只有这一种这么单一啦,再加上一个支持添加途径点得线路规划
功能岂不美哉?
效果截图和源码地址
UI截图
导出效果截图
仓库地址 : github.com/zuowenwu/Li…
手绘线路+线路纠偏 代码实现
首先是要明确画线的操作,分三步:按下、画线和抬起的操作:
this.map.on("touchstart", (e) => {});// 准备画线
this.map.on("touchend", (e) => {});// 结束画线
this.map.on("touchmove");// 画线中
最重要的代码是画线的操作,此时我们设置为地图不可拖动,然后记录手指在地图上的位置即可:
//路径
this.path = []
// 监听滑动配合节流(这里节流是为了减少采样过快避免造成不必要的开销)
this.map.on("touchmove",_.throttle((e) => {
// 点
const position = [e.lnglat.lng, e.lnglat.lat];
// 数组长度为0则第一个点为起点marker
if (!this.path.length) {
this.path.push(position);
new this.AMap.Marker({ map: this.map, position: position });
return;
}
//满足两点创建线
if (this.path.length == 1) {
this.path.push(position);
this.line = new this.AMap.Polyline({
map: this.map,
path: this.path,
strokeColor: "#FF33FF",
strokeWeight: 6,
strokeOpacity: 0.5,
});
return;
}
//添加path
if (this.path.length > 1) {
this.path.push(position);
this.line.setPath(this.path);
}
}, 30)
);
线连接好了,可以导出了!。。吗?那肯定不是,手指在屏幕上画线肯定会和道路有很大的偏差的,我们可以使用高德的线路纠偏功能,因为该功能需要方向角、速度和时间,我们可以把刚刚模拟的线路path设置一下:
let arr = this.path.map((item, index) => {
// 默认角度
let angle = 0;
// 初始时间戳
let tm = 1478031031;
// 和下一个点的角度
if (this.path[index + 1]) {
// 计算与正北方向的夹角
const north = turf.bearing(turf.point([item[0], item[1]]), turf.point([item[0], item[1] + 1]));
// 使用正北方向的点
angle = north < 0 ? (360 + north) : north;
}
return {
x: item[0], //经度
y: item[1],//维度
sp: 10,//速度
ag: Number(angle).toFixed(0),//与正北的角度
tm: !index ? tm : 1 + index,//时间
};
});
这里的数据格式就是这样的:
要注意一下,第一个tm是初始的时间戳,后面都是在[index-1]+距离上次的时间
,角度则是与正北方向的夹角而不是和上一个点的夹角,这里我差点弄混淆了
然后使用线路纠偏:
graspRoad.driving(arr, (error, result) => {
if (!error) {
var path2 = [];
var newPath = result.data.points;
for (var i = 0; i < newPath.length; i += 1) {
path2.push([newPath[i].x, newPath[i].y]);
}
var newLine = new this.AMap.Polyline({
path: path2,
strokeWeight: 8,
strokeOpacity: 0.8,
strokeColor: "#00f",
showDir: true,
});
this.map.add(newLine);
}
});
绿色是手动画的线,蓝色是纠偏到道路上的线,可以看的出来效果还是很不错的
OK!接下来是导出手机或者码表使用的GPX
格式文件的代码,这里使用插件geojson-to-gpx
,直接npm i geojson-to-gpx
即可,然后导入使用,代码如下:
import GeoJsonToGpx from "@dwayneparton/geojson-to-gpx";
// 转为GeoJSON
const geoJSON = turf.lineString(this.path);
const options = {
metadata: {
name: "导出为GPX",
author: {
name: "XiaoZuoOvO",
},
},
};
//转为geoJSON
const gpxLine = GeoJsonToGpx(geoJSON, options);
const gpxString = new XMLSerializer().serializeToString(gpxLine);
const link = document.createElement("a");
link.download = "高德地图路线绘制.gpx";
const blob = new Blob([gpxString], { type: "text/xml" });
link.href = window.URL.createObjectURL(blob);
link.click();
ElMessage.success("导出PGX成功");
好的,以上就是手绘线路的大概功能!接下来是我们的线路规划功能。
起终点和定义途径点的线路规划 代码实现
虽然说这个功能大多骑行软件都有,但是我们要做就做好用的,支持添加途径点,我们这里使用高德的线路规划2.0
,这个API支持添加途径点,再配合上elementplus的el-autocomplete配合搜索,搜索地点使用搜索POI2.0
来搜索地点,以下是代码实现,完整代码在github
//html
<el-autocomplete
:prefix-icon="Location"
v-model.trim="start"
:trigger-on-focus="false"
clearable
size="large"
placement="top-start"
:fetch-suggestions="querySearch"
@select="handleSelectStart"
placeholder="起点" />
//js
//搜索地点函数
const querySearch = async (queryString, cb) => {
if (!queryString) return;
const res = await inputtips(queryString);//inputtips是封装好的
if (res.status == "1") {
const arr = res.tips.map((item) => {
return {
value: item.name,
name: item.name,
district: item.district,
address: item.address,
location: item.location,
};
});
cb(arr);
return;
}
};
//自行车路径规划函数
const plan = async () => {
path = [];
const res = await driving({
origin: startPositoin.value,//起点
destination: endPosition.value,//终点
cartype: 1, //电动车/自行车
waypoints: means.value.map((item) => item.location).join(";"),//途径点
});
if (res.status == "1") {
res.route.paths[0].steps.map((item) => {
const linestring = item.polyline;
path = path.concat(
linestring.split(";").map((item) => {
const arr = item.split(",");
return [Number(arr[0]), Number(arr[1])];
})
);
});
}
};
//......................完整代码见github..............................
搜索和规划效果截图:
以上就是手绘线路和途径点起点终点两个功能,接下来我们干个题外事,我们优化一下高德的 setCenter 和 setFitView,高德的动画太过于线性,我们这里模仿一下cesium和mapbox的效果,使用丝滑贝塞尔曲线来插值过度,配合高德Loca镜头动画
动画效果优化
首先是写一个setCenter
,使用的时候传入即可,效果图和代码:
export function panTo(center, map, loca) {
const curZoom = map.getZoom();
const curPitch = map.getPitch();
const curRotation = map.getRotation();
const curCenter = [map.getCenter().lng, map.getCenter().lat];
const targZoom = 17;
const targPitch = 45;
const targRotation = 0;
const targCenter = center;
const route = [
{
pitch: {
value: targPitch,
duration: 2000,
control: [
[0, curPitch],
[1, targPitch],
],
timing: [0.420, 0.145, 0.000, 1],
},
zoom: {
value: targZoom,
duration: 2500,
control: [
[0, curZoom],
[1, targZoom],
],
timing: [0.315, 0.245, 0.405, 1.000],
},
rotation: {
value: targRotation,
duration: 2000,
control: [
[0, curRotation],
[1, targRotation],
],
timing: [1.000, 0.085, 0.460, 1],
},
center: {
value: targCenter,
duration: 1500,
control: [curCenter, targCenter],
timing: [0.0, 0.52, 0.315, 1.0],
},
},
];
// 如果用户有操作则停止动画
map.on("mousewheel", () => {
loca.animate.stop();
});
loca.viewControl.addAnimates(route, () => {});
loca.animate.start();
}
接下来是setFitView:
export function setFitView(center, zoom, map, loca) {
const curZoom = map.getZoom();
const curPitch = map.getPitch();
const curRotation = map.getRotation();
const curCenter = [map.getCenter().lng, map.getCenter().lat];
const targZoom = zoom;
const targPitch = 0;
const targRotation = 0;
const targCenter = center;
const route = [
{
pitch: {
value: targPitch,
duration: 1000,
control: [
[0, curPitch],
[1, targPitch],
],
timing: [0.23, 1.0, 0.32, 1.0],
},
zoom: {
value: targZoom,
duration: 2500,
control: [
[0, curZoom],
[1, targZoom],
],
timing: [0.13, 0.31, 0.105, 1],
},
rotation: {
value: targRotation,
duration: 1000,
control: [
[0, curRotation],
[1, targRotation],
],
timing: [0.13, 0.31, 0.105, 1],
},
center: {
value: targCenter,
duration: 1000,
control: [curCenter, targCenter],
timing: [0.13, 0.31, 0.105, 1],
},
},
];
// 如果用户有操作则停止动画
map.on("mousewheel", () => {
loca.animate.stop();
});
loca.viewControl.addAnimates(route, () => {});
loca.animate.start();
}
export function getFitCenter(points) {
let features = turf.featureCollection(points.map((point) => turf.point(point)));
let center = turf.center(features);
return [center.geometry.coordinates[0], center.geometry.coordinates[1]];
}
export function setFitCenter(points, map) {
const center = getFitCenter(points);
}
//使用
setFitView(getFitCenter(path), getFitZoom(map, path), map, loca);
结束
先贴上仓库地址:
github.com/zuowenwu/Li…
最后送几张自己拍的照片吧哈哈哈
来源:juejin.cn/post/7430616540804153394
Flutter 中在单个屏幕上实现多个列表
今天,我将提供一个实际的示例,演示如何在单个页面上实现多个列表,这些列表可以水平排列、网格格式、垂直排列,甚至是这些常用布局的组合。
下面是要做的:
实现
让我们从创建一个包含产品所有属性的产品模型开始。
class Product {
final String id;
final String name;
final double price;
final String image;
const Product({
required this.id,
required this.name,
required this.price,
required this.image,
});
factory Product.fromJson(Map json) {
return Product(
id: json['id'],
name: json['name'],
price: json['price'],
image: json['image'],
);
}
}
现在,我们将设计我们的小部件以支持水平、垂直和网格视图。
创建一个名为 HorizontalRawWidget 的新窗口小部件类,定义水平列表的用户界面。
import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';
class HorizontalRawWidget extends StatelessWidget {
final Product product;
const HorizontalRawWidget({Key? key, required this.product})
: super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
left: 15,
),
child: Container(
width: 125,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(5, 5, 5, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
product.image,
height: 130,
fit: BoxFit.contain,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.bold)),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text("\$${product.price}",
style: const TextStyle(
color: Colors.black, fontSize: 12)),
],
),
],
),
),
)
],
),
),
);
}
}
设计一个名为 GridViewRawWidget 的小部件类,定义单个网格视图的用户界面。
import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';
class GridViewRawWidget extends StatelessWidget {
final Product product;
const GridViewRawWidget({Key? key, required this.product}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Column(
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
product.image,
fit: BoxFit.fill,
),
),
)
],
),
);
}
}
最后,让我们为垂直视图创建一个小部件类。
import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';
class VerticalRawWidget extends StatelessWidget {
final Product product;
const VerticalRawWidget({Key? key, required this.product}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
color: Colors.white,
child: Row(
children: [
Image.network(
product.image,
width: 78,
height: 88,
),
const SizedBox(
width: 15,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontSize: 12, color: Colors.black, fontWeight: FontWeight.bold),
),
SizedBox(
height: 5,
),
Text("\$${product.price}",
style: const TextStyle(color: Colors.black, fontSize: 12)),
],
),
)
],
),
);
}
}
现在是时候把所有的小部件合并到一个屏幕中了,我们先创建一个名为“home_page.dart”的页面,在这个页面中,我们将使用一个横向的 ListView、纵向的 ListView 和 GridView。
import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';
import 'package:multiple_listview_example/utils/product_helper.dart';
import 'package:multiple_listview_example/views/widgets/gridview_raw_widget.dart';
import 'package:multiple_listview_example/views/widgets/horizontal_raw_widget.dart';
import 'package:multiple_listview_example/views/widgets/title_widget.dart';
import 'package:multiple_listview_example/views/widgets/vertical_raw_widget.dart';
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
List products = ProductHelper.getProductList();
return Scaffold(
backgroundColor: const Color(0xFFF6F5FA),
appBar: AppBar(
centerTitle: true,
title: const Text("Home"),
),
body: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const TitleWidget(title: "Horizontal List"),
const SizedBox(
height: 10,
),
SizedBox(
height: 200,
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: products.length,
itemBuilder: (BuildContext context, int index) {
return HorizontalRawWidget(
product: products[index],
);
}),
),
const SizedBox(
height: 10,
),
const TitleWidget(title: "Grid View"),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
child: GridView.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 13,
mainAxisSpacing: 13,
childAspectRatio: 1),
itemCount: products.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
return GridViewRawWidget(
product: products[index],
);
}),
),
const TitleWidget(title: "Vertical List"),
ListView.builder(
itemCount: products.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
return VerticalRawWidget(
product: products[index],
);
}),
],
),
),
),
);
}
}
我使用了一个 SingleChildScrollView
widget 作为代码中的顶部根 widget,考虑到我整合了多个布局,如水平列表、网格视图和垂直列表,我将所有这些 widget 包装在一个 Column
widget 中。
挑战在于如何处理多个滚动部件,因为在上述示例中有两个垂直滚动部件:一个网格视图和一个垂直列表视图。为了禁用单个部件的滚动行为, physics
属性被设置为 const NeverScrollableScrollPhysics()
。取而代之的是,使用顶层根 SingleChildScrollView`` 来启用整个内容的滚动。此外,
SingleChildScrollView上的
shrinkWrap属性被设置为
true`,以确保它能紧紧包裹其内容,只占用其子控件所需的空间。
Github 链接:github.com/tarunaronno…
来源:juejin.cn/post/7302070112638468147
flutter3-douyin:基于flutter3.x+getx+mediaKit短视频直播App应用
经过大半个月的爆肝式开发输出,又一个跨端新项目Flutter-Douyin短视频
正式完结了。
flutter3_douyin基于最新跨平台技术flutter3.19.2
开发手机端仿抖音app实战项目。
实现了类似抖音全屏沉浸式上下滑动视频、左右滑动切换页面模块,直播间进场/礼物动画,聊天等模块功能。
使用技术
- 编辑器:vscode
- 技术框架:flutter3.19.2+dart3.3.0
- 路由/状态插件:get: ^4.6.6
- 本地缓存服务:get_storage: ^2.1.1
- 图片预览插件:photo_view: ^0.14.0
- 刷新加载:easy_refresh^3.3.4
- toast轻提示:toast^0.3.0
- 视频套件:media_kit: ^1.1.10+1
项目结构
前期需要配置好flutter和dart sdk环境。如果使用vscode编辑器,可以安装一些flutter语法插件。
更多的开发api资料,大家可以去官网查阅就行。
flutter.dev/
flutter.cn/
pub.flutter-io.cn/
http://www.dartcn.com/
该项目涉及到的技术知识还是蛮多的。下面主要介绍一些短视频及直播知识,至于其它知识点,大家可以去看看之前分享的flutter3聊天项目文章。
http://www.cnblogs.com/xiaoyan2017…
http://www.cnblogs.com/xiaoyan2017…
flutter主入口lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:media_kit/media_kit.dart';
import 'utils/index.dart';
// 引入布局模板
import 'layouts/index.dart';
import 'binding/binding.dart';
// 引入路由管理
import 'router/index.dart';
void main() async {
// 初始化get_storage
await GetStorage.init();
// 初始化media_kit
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'FLUTTER3 DYLIVE',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFE2C55)),
useMaterial3: true,
// 修正windows端字体粗细不一致
fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null,
),
home: const Layout(),
// 全局绑定GetXController
initialBinding: GlobalBindingController(),
// 初始路由
initialRoute: Utils.isLogin() ? '/' : '/login',
// 路由页面
getPages: routePages,
// 错误路由
// unknownRoute: GetPage(name: '/404', page: Error),
);
}
}
flutter3自定义底部凸起导航
采用 bottomNavigationBar
组件实现页面模块切换。通过getx
状态管理联动控制底部导航栏背景颜色。导航栏中间图标/图片按钮,使用了 Positioned
组件实现功能。
return Scaffold(
backgroundColor: Colors.grey[50],
body: pageList[pageCurrent],
// 底部导航栏
bottomNavigationBar: Theme(
// Flutter去掉BottomNavigationBar底部导航栏的水波纹
data: ThemeData(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
),
child: Obx(() {
return Stack(
children: [
Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Colors.black54, width: .1)),
),
child: BottomNavigationBar(
backgroundColor: bottomNavigationBgcolor(),
fixedColor: FStyle.primaryColor,
unselectedItemColor: bottomNavigationItemcolor(),
type: BottomNavigationBarType.fixed,
elevation: 1.0,
unselectedFontSize: 12.0,
selectedFontSize: 12.0,
currentIndex: pageCurrent,
items: [
...pageItems
],
onTap: (index) {
setState(() {
pageCurrent = index;
});
},
),
),
// 自定义底部导航栏中间按钮
Positioned(
left: MediaQuery.of(context).size.width / 2 - 15,
top: 0,
bottom: 0,
child: InkWell(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon(Icons.tiktok, color: bottomNavigationItemcolor(centerDocked: true), size: 32.0,),
Image.asset('assets/images/applogo.png', width: 32.0, fit: BoxFit.contain,)
// Text('直播', style: TextStyle(color: bottomNavigationItemcolor(centerDocked: true), fontSize: 12.0),)
],
),
onTap: () {
setState(() {
pageCurrent = 2;
});
},
),
),
],
);
}),
),
);
flutter3实现抖音滑动效果
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
forceMaterialTransparency: true,
backgroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? null : Colors.transparent,
foregroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? Colors.black : Colors.white,
titleSpacing: 1.0,
leading: Obx(() => IconButton(icon: Icon(Icons.menu, color: tabColor(),), onPressed: () {},),),
title: Obx(() {
return TabBar(
controller: tabController,
tabs: pageTabs.map((v) => Tab(text: v)).toList(),
isScrollable: true,
tabAlignment: TabAlignment.center,
overlayColor: MaterialStateProperty.all(Colors.transparent),
unselectedLabelColor: unselectedTabColor(),
labelColor: tabColor(),
indicatorColor: tabColor(),
indicatorSize: TabBarIndicatorSize.label,
unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'),
labelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w600),
dividerHeight: 0,
labelPadding: const EdgeInsets.symmetric(horizontal: 10.0),
indicatorPadding: const EdgeInsets.symmetric(horizontal: 5.0),
onTap: (index) {
pageVideoController.updatePageVideoTabIndex(index); // 更新索引
pageController.jumpToPage(index);
},
);
}),
actions: [
Obx(() => IconButton(icon: Icon(Icons.search, color: tabColor(),), onPressed: () {},),),
],
),
body: Column(
children: [
Expanded(
child: Stack(
children: [
/// 水平滚动模块
PageView(
// 自定义滚动行为(支持桌面端滑动、去掉滚动条槽)
scrollBehavior: PageScrollBehavior().copyWith(scrollbars: false),
scrollDirection: Axis.horizontal,
controller: pageController,
onPageChanged: (index) {
pageVideoController.updatePageVideoTabIndex(index); // 更新索引
setState(() {
tabController.animateTo(index);
});
},
children: [
...pageModules
],
),
],
),
),
],
),
);
flutter实现直播功能
// 商品购买动效
Container(
...
),
// 加入直播间动效
const AnimationLiveJoin(
joinQueryList: [
{'avatar': 'assets/images/logo.png', 'name': 'andy'},
{'avatar': 'assets/images/logo.png', 'name': 'jack'},
{'avatar': 'assets/images/logo.png', 'name': '一条咸鱼'},
{'avatar': 'assets/images/logo.png', 'name': '四季平安'},
{'avatar': 'assets/images/logo.png', 'name': '叶子'},
],
),
// 送礼物动效
const AnimationLiveGift(
giftQueryList: [
{'label': '小心心', 'gift': 'assets/images/gift/gift1.png', 'user': 'Jack', 'avatar': 'assets/images/avatar/uimg2.jpg', 'num': 12},
{'label': '棒棒糖', 'gift': 'assets/images/gift/gift2.png', 'user': 'Andy', 'avatar': 'assets/images/avatar/uimg6.jpg', 'num': 36},
{'label': '大啤酒', 'gift': 'assets/images/gift/gift3.png', 'user': '一条咸鱼', 'avatar': 'assets/images/avatar/uimg1.jpg', 'num': 162},
{'label': '人气票', 'gift': 'assets/images/gift/gift4.png', 'user': 'Flower', 'avatar': 'assets/images/avatar/uimg5.jpg', 'num': 57},
{'label': '鲜花', 'gift': 'assets/images/gift/gift5.png', 'user': '四季平安', 'avatar': 'assets/images/avatar/uimg3.jpg', 'num': 6},
{'label': '捏捏小脸', 'gift': 'assets/images/gift/gift6.png', 'user': 'Alice', 'avatar': 'assets/images/avatar/uimg4.jpg', 'num': 28},
{'label': '你真好看', 'gift': 'assets/images/gift/gift7.png', 'user': '叶子', 'avatar': 'assets/images/avatar/uimg7.jpg', 'num': 95},
{'label': '亲吻', 'gift': 'assets/images/gift/gift8.png', 'user': 'YOYO', 'avatar': 'assets/images/avatar/uimg8.jpg', 'num': 11},
{'label': '玫瑰', 'gift': 'assets/images/gift/gift12.png', 'user': '宇辉', 'avatar': 'assets/images/avatar/uimg9.jpg', 'num': 3},
{'label': '私人飞机', 'gift': 'assets/images/gift/gift16.png', 'user': 'Hison', 'avatar': 'assets/images/avatar/uimg10.jpg', 'num': 273},
],
),
// 直播弹幕+商品讲解
Container(
margin: const EdgeInsets.only(top: 7.0),
height: 200.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: liveJson[index]['message']?.length,
itemBuilder: (context, i) => danmuList(liveJson[index]['message'])[i],
),
),
SizedBox(
width: isVisibleGoodsTalk ? 7 : 35,
),
// 商品讲解
Visibility(
visible: isVisibleGoodsTalk,
child: Column(
...
),
),
],
),
),
// 底部工具栏
Container(
margin: const EdgeInsets.only(top: 7.0),
child: Row(
...
),
),
flutter直播通过 SlideTransition
组件实现直播进场动画。
return SlideTransition(
position: animationFirst ? animation : animationMix,
child: Container(
alignment: Alignment.centerLeft,
margin: const EdgeInsets.only(top: 7.0),
padding: const EdgeInsets.symmetric(horizontal: 7.0,),
height: 23.0,
width: 250,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFF6301FF), Colors.transparent
],
),
borderRadius: BorderRadius.horizontal(left: Radius.circular(10.0)),
),
child: joinList!.isNotEmpty ?
Text('欢迎 ${joinList![0]['name']} 加入直播间', style: const TextStyle(color: Colors.white, fontSize: 14.0,),)
:
Container()
,
),
);
class _AnimationLiveJoinState extends State<AnimationLiveJoin> with TickerProviderStateMixin {
// 动画控制器
late AnimationController controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500), // 第一个动画持续时间
);
late AnimationController controllerMix = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000), // 第二个动画持续时间
);
// 动画
late Animation<Offset> animation = Tween(begin: const Offset(2.5, 0), end: const Offset(0, 0)).animate(controller);
late Animation<Offset> animationMix = Tween(begin: const Offset(0, 0), end: const Offset(-2.5, 0)).animate(controllerMix);
Timer? timer;
// 是否第一个动画
bool animationFirst = true;
// 是否空闲
bool idle = true;
// 加入直播间数据列表
List? joinList;
@override
void initState() {
super.initState();
joinList = widget.joinQueryList!.toList();
runAnimation();
animation.addListener(() {
if(animation.status == AnimationStatus.forward) {
debugPrint('第一个动画进行中');
idle = false;
setState(() {});
}else if(animation.status == AnimationStatus.completed) {
debugPrint('第一个动画结束');
animationFirst = false;
if(controllerMix.isCompleted || controllerMix.isDismissed) {
timer = Timer(const Duration(seconds: 2), () {
controllerMix.forward();
debugPrint('第二个动画开始');
});
}
setState(() {});
}
});
animationMix.addListener(() {
if(animationMix.status == AnimationStatus.forward) {
setState(() {});
}else if(animationMix.status == AnimationStatus.completed) {
animationFirst = true;
controller.reset();
controllerMix.reset();
if(joinList!.isNotEmpty) {
joinList!.removeAt(0);
}
idle = true;
// 执行下一个数据
runAnimation();
setState(() {});
}
});
}
void runAnimation() {
if(joinList!.isNotEmpty) {
// 空闲状态才能执行,防止添加数据播放状态混淆
if(idle == true) {
if(controller.isCompleted || controller.isDismissed) {
setState(() {});
timer = Timer(Duration.zero, () {
controller.forward();
});
}
}
}
}
@override
void dispose() {
controller.dispose();
controllerMix.dispose();
timer?.cancel();
super.dispose();
}
}
以上只是介绍了一部分知识点,限于篇幅就先介绍这么多,希望有所帮助~
juejin.cn/post/731918…
来源:juejin.cn/post/7349542148733960211
我穿越回2013年,拿到一台旧电脑,只为给Android2.3设备写一个时钟程序
昨天收拾屋子,翻出一台 lenovo A360e
,其搭载联了发科单核芯片(MT6567)的3G智能(Android 2.3.6)手机,上市于2012年,于2017年停产。其屏幕尺寸为3.5英寸,分辨率是480x320像素。具备重力感应、光线感应和距离传感器。
然而,现在是2024年。几乎没有什么应用可以在Android2.3上面跑了。
所以,打开 AndroidStudio,新建一个项目。
完犊子了,最低只支持Android5.0!
好吧,我立刻坐进时光机,穿越到2013年,拿到当年我的一台旧电脑。上面有Android2.2的开发环境。
新建一个 Android 2.2 的项目。
接下来就是 xml 布局。对于习惯 jetpack components的人来讲,xml布局简直就是一坨屎。但是没办法,为了能在 Android 2.3 上面跑,只好硬着头搞了。
首先画一个简单的布局图:
看起来有点复杂,其实一点也不简单。
但是,可以先做上下结构:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000"
tools:context="org.deviceartist.clock.FullscreenActivity" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
</LinearLayout>
</FrameLayout>
然后在下面的结构中,再分出一个左右结构:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000"
tools:context="org.deviceartist.clock.FullscreenActivity" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal" >
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="250sp"
android:gravity="center_vertical" >
</LinearLayout>
</LinearLayout>
</FrameLayout>
然后按照布局图写 xml 的 layout 文件:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000"
tools:context="org.deviceartist.clock.FullscreenActivity" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal" >
<TextView
android:id="@+id/tab1"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="left"
android:padding="0dp"
android:text="STAT"
android:textColor="@color/green"
android:textSize="22sp" />
<TextView
android:id="@+id/tab2"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="center"
android:padding="0dp"
android:text="INV"
android:textColor="@color/green"
android:textSize="22sp" />
<TextView
android:id="@+id/tab3"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="2"
android:gravity="center"
android:padding="0dp"
android:text="DATA"
android:textColor="@color/green"
android:textSize="22sp" />
<TextView
android:id="@+id/tab4"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="center"
android:padding="0dp"
android:text="MAP"
android:textColor="@color/green"
android:textSize="22sp" />
<TextView
android:id="@+id/tab5"
android:layout_width="50sp"
android:layout_height="30sp"
android:layout_weight="1"
android:gravity="right"
android:padding="0dp"
android:text="TERMUX"
android:textColor="@color/green"
android:textSize="22sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="250sp"
android:gravity="center_vertical" >
<TextView
android:id="@+id/textViewTime"
android:layout_width="210sp"
android:layout_height="200sp"
android:textSize="100sp" />
<TextView
android:id="@+id/textViewTimeS"
android:gravity="center"
android:layout_width="50sp"
android:layout_height="150sp"
android:textSize="20sp" />
<org.deviceartist.clock.MyCanvas
android:id="@+id/myCanvas"
android:layout_width="200sp"
android:layout_height="200sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
相应的 java 代码
package org.deviceartist.clock;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Timer;
import org.deviceartist.clock.util.SystemUiHider;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.graphics.Typeface;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.TextView;
public class FullscreenActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
if (Build.VERSION.SDK_INT < 16) {
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
setContentView(R.layout.activity_fullscreen);
Typeface typeface = Typeface.createFromAsset(this.getAssets(), "fonts/font.ttf");
final TextView textViewTime = (TextView) findViewById(R.id.textViewTime);
final TextView textViewTimeS = (TextView) findViewById(R.id.textViewTimeS);
final MyCanvas c = (MyCanvas) findViewById(R.id.myCanvas);
textViewTime.setTextColor(0xff5CB31D);
textViewTime.setTypeface(typeface);
textViewTimeS.setTextColor(0xff5CB31D);
textViewTimeS.setTypeface(typeface);
final Handler handler = new Handler();
Runnable runnable = new Runnable(){
@Override
public void run() {
String currentTime = new SimpleDateFormat("HH\nmm",Locale.getDefault()).format(new Date());
textViewTime.setText(currentTime);
String currentTimeS = new SimpleDateFormat("ss",Locale.getDefault()).format(new Date());
textViewTimeS.setText(currentTimeS);
handler.postDelayed(this, 1000);
}
};
handler.postDelayed(runnable, 0);
final Handler handler2 = new Handler();
Runnable runnable2 = new Runnable(){
@Override
public void run() {
c.next();
handler.postDelayed(this, 100);
}
};
handler2.postDelayed(runnable2, 100);
}
}
知识点:
1、定时器
final Handler handler = new Handler();
Runnable runnable = new Runnable(){
@Override
public void run() {
//todo
}
};
handler.postDelayed(runnable, 0);
2、Canvas画布就是自定义的View类
关键代码:
package org.deviceartist.clock;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Locale;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.View;
public class MyCanvas extends View {
private Paint paint;
Canvas canvas;
public MyCanvas(Context context) {
super(context);
init();
}
public MyCanvas(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public MyCanvas(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
paint = new Paint();
paint.setColor(0xff5CB31D);
paint.setStyle(Paint.Style.FILL);
}
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
}
全部代码:
package org.deviceartist.clock;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Locale;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.View;
public class MyCanvas extends View {
private int index = 0;
ArrayList<Bitmap> bitmaps = new ArrayList<>();
Bitmap voltage;
Bitmap nuclear;
Bitmap shield;
Bitmap aim;
Bitmap gun;
Bitmap helmet;
private Paint paint;
Canvas canvas;
public MyCanvas(Context context) {
super(context);
init();
// TODO Auto-generated constructor stub
}
public MyCanvas(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public MyCanvas(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
voltage = BitmapFactory.decodeResource(getResources(),
R.drawable.voltage);
nuclear = BitmapFactory.decodeResource(getResources(),
R.drawable.nuclear);
shield = BitmapFactory.decodeResource(getResources(),
R.drawable.shield);
aim = BitmapFactory.decodeResource(getResources(),
R.drawable.aim);
gun = BitmapFactory.decodeResource(getResources(),
R.drawable.gun);
helmet = BitmapFactory.decodeResource(getResources(),
R.drawable.helmet);
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy1));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy2));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy3));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy4));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy5));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy6));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy7));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy8));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy9));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy10));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy11));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy12));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy13));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy14));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy15));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy16));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy17));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy18));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy19));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy10));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy11));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy12));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy13));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy14));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy15));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy16));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy17));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy18));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy19));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy20));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy21));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy22));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy23));
bitmaps.add(BitmapFactory.decodeResource(getResources(),
R.drawable.boy24));
paint = new Paint();
paint.setColor(0xff5CB31D); // 设置圆形的颜色
paint.setStyle(Paint.Style.FILL); // 设置填充样式
}
void next() {
index += 1;
index += 1;
if (index == 24) {
index = 0;
}
invalidate();
}
protected void onDraw(Canvas canvas) {
this.canvas = canvas;
super.onDraw(canvas);
Bitmap bitmap = bitmaps.get(index);
int w = bitmap.getWidth();
int h = bitmap.getHeight();
// 获取View的中心点坐标
int x = getWidth() / 2 - w/2;
int y = getHeight() / 2 - h/2;
canvas.drawBitmap(bitmap, x, y, paint);
canvas.drawLine(10, 20, 55, 20, paint);
canvas.drawLine(55, 20, 90, 70, paint);
canvas.drawBitmap(shield, 10, 30, paint);
canvas.drawLine(50, getHeight()/2, 100, getHeight()/2, paint);
canvas.drawText("98%", 50, getHeight()/2-10, paint);
canvas.drawBitmap(voltage, 10, getHeight()/2-30, paint);
canvas.drawLine(10, getHeight()-30, 90, getHeight()-30, paint);
canvas.drawLine(90, getHeight()-30, 100, getHeight()-80, paint);
canvas.drawBitmap(gun, 10, getHeight()-90, paint);
canvas.drawLine(getWidth()-30, 20, getWidth(), 20, paint);
canvas.drawLine(getWidth()-30, 20, getWidth()-90, 70, paint);
canvas.drawBitmap(aim, getWidth()-40, 30, paint);
canvas.drawLine(getWidth()-110, getHeight()/2, getWidth()-50, getHeight()/2, paint);
canvas.drawText("9.9", getWidth()-80, getHeight()/2-10, paint);
canvas.drawBitmap(nuclear, getWidth()-50, getHeight()/2-30, paint);
canvas.drawLine(getWidth()-100, getHeight()-80, getWidth()-70, getHeight()-30, paint);
canvas.drawLine(getWidth()-70, getHeight()-30, getWidth(), getHeight()-30, paint);
canvas.drawBitmap(helmet, getWidth()-70, getHeight()-90, paint);
}
}
最终效果
源码地址:
git.sr.ht/~devicearti…
来源:juejin.cn/post/7431455141084528650
用js手撸了一个zip saver
背景介绍
最近公司有个需求,要在浏览器端生成一大堆的 word 文件并保存到本地。
生成 word 文件直接用了docx这个库,嗖的一下很快就搞定了。但是交付给需求方的时候他们却说生成的文件乱糟糟的放在下载目录里面他们看着烦,而且还要手动整理每一批文件,问我能不能搞成一个压缩包。我一听这个要求,心想:不就是调的包的事吗,二话不说马上就答应了。
然而,,,,,
搜了很久,也没有找到直接马上就可以用的 js 库来将一大堆文件直接变成一个压缩包。又搜了一下 zip 的文件格式内容,发现好像不是很复杂。那就自己来搞一个包吧。
最近公司有个需求,要在浏览器端生成一大堆的 word 文件并保存到本地。
生成 word 文件直接用了docx这个库,嗖的一下很快就搞定了。但是交付给需求方的时候他们却说生成的文件乱糟糟的放在下载目录里面他们看着烦,而且还要手动整理每一批文件,问我能不能搞成一个压缩包。我一听这个要求,心想:不就是调的包的事吗,二话不说马上就答应了。
然而,,,,,
搜了很久,也没有找到直接马上就可以用的 js 库来将一大堆文件直接变成一个压缩包。又搜了一下 zip 的文件格式内容,发现好像不是很复杂。那就自己来搞一个包吧。
太长不看?直接用 zip-saver
一、zip 文件格式简介
zip 文件大致可以分成三个个部分:
- 文件部分
- 文件部分包含了所有的文件内容,每个文件都有一个文件头,文件头包含了文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
- 中央目录部分
- 中央目录部分包含了所有文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
- 目录结束标识 - 目录结束标识标识了中央目录部分的结束。包含了中央目录的开始位置、中央目录的大小等信息。
图片来自:en.wikipedia.org/wiki/ZIP_(f…
对于每一个文件,他在 zip 中包含三部分
- 本地文件头( Local File Header)-- 图片来自:goodapple.top/archives/70…

- 文件内容
- 数据描述符( Data descriptor)-- 图片来自:goodapple.top/archives/70…
zip 文件大致可以分成三个个部分:
- 文件部分
- 文件部分包含了所有的文件内容,每个文件都有一个文件头,文件头包含了文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
- 中央目录部分
- 中央目录部分包含了所有文件的元信息,比如文件名、文件大小、文件的压缩方式等等。
- 目录结束标识 - 目录结束标识标识了中央目录部分的结束。包含了中央目录的开始位置、中央目录的大小等信息。
图片来自:en.wikipedia.org/wiki/ZIP_(f…
对于每一个文件,他在 zip 中包含三部分
- 本地文件头( Local File Header)-- 图片来自:goodapple.top/archives/70…
- 文件内容
- 数据描述符( Data descriptor)-- 图片来自:goodapple.top/archives/70…
数据描述符是可选的,当本地文件头中没有指明 CRC-32 校验码和压缩前后的长度时,才需要数据描述符
中央目录区的数据构成是这样的 -- 图片来自:goodapple.top/archives/70…
目录结束标识的数据构成是这样的 -- 图片来自:goodapple.top/archives/70…
二、代码实现
有了上面的信息之后,不难想到生成一个 zip 文件的步骤:
- 生成文件部分
- 构造固定的文件信息头
- 追加文件内容
- 计算文件的 CRC32 校验码
- 生成数据描述符
- 生成中央目录部分
- 构造固定的中央文件信息头
- 计算文件的偏移量
- 生成目录结束标识
- 构造固定的目录结束标识
- 计算中央目录的大小和偏移
1. 生成本地文件头(local file header)
根据local file header
的结构,我们很容易得知:一个local file header
的大小是 30 + n + m
个字节
其中n
是文件名的长度,m
是扩展字段的长度,在这里我们不考虑扩展字段,那么最终大小就是30 + n
在js
中我可以直接用Uint8Array
来存储一个字节,又因为 zip 是采用小端序,为了方便操作, 那么local file header
变量就可以这样定义:
const length = 30 + filenameLength
const localFileHeaderBytes = new Uint8Array(length)
// 使用DataView可以更方便的操作小端序数据
const localFileHeaderDataView = new DataView(localFileHeaderBytes.buffer)
定义完 local file header 变量后我们就可以往里面塞一些东西了
// local file header 的起始固定值为 0x04034b50
// setUint第一个参数为偏移量,第二个参数是值,第三个参数为true表示以小端序存储
localFileHeaderDataView.setUint32(0, 0x04034b50, true)
// 设置最低要求的版本号为 0x14
localFileHeaderDataView.setUint16(4, 0x0014, true)
// 设置通用标志位为 0x0808
// 0x0808 使用UTF-8编码且文件头中不包含CRC32和文件大小信息
localFileHeaderDataView.setUint16(6, 0x0808, true)
// 设置压缩方式为 0x0000 表示不压缩
localFileHeaderDataView.setUint16(8, 0x0000, true)
// 设置最后修改时间, 这里假设最后修改时间为当前时间
const lastModified = new Date().getTime()
// last modified time
localFileHeader.setUint16(
10,
(date.getUTCHours() << 11) |
(date.getUTCMinutes() << 5) |
(date.getUTCSeconds() / 2)
)
// last modified date
localFileHeader.setUint16(
12,
date.getUTCDate() |
((date.getUTCMonth() + 1) << 5) |
((date.getUTCFullYear() - 1980) << 9)
)
// 设置文件名的长度,这里假设文件名已经转换成了字节数组nameBytes
localFileHeaderDataView.setUint16(26, nameBytes.length, true)
// 设置文件名
localFileHeaderBytes.set(nameBytes, 30)
到此,一个local file header
就生成好了
2. 文件内容追加
文件内容追加这一步很简单,这里我们不考虑压缩文件,直接将文件转为Uint8Array
并计算文件的 CRC32 校验码,然后追加到local file header
后面即可
const crc = new CRC32()
// 获取file数据备用
const fileBytes = await file.arrayBuffer()
crc.append(fileBytes)
3. 数据描述符(Data descriptor)生成
数据描述符用来表示文件压缩与的结束,根据他的编码格式,他包含的信息只有四个:固定的标识符、CRC-32校验码,压缩前的大小,压缩后的大小,这里我们暂且不考虑数据的压缩, 要生成他也很简单:
const dataDescriptor = new Uint8Array(16)
const dataDescriptorDataView = new DataView(dataDescriptor.buffer)
// 0x08074b50 是数据描述符的固定标识字段
dataDescriptorDataView.setUint32(0, 0x08074b50, true)
// CRC-32校验码
dataDescriptorDataView.setUint32(4, crc.value, true)
// 压缩前的大小
dataDescriptorDataView.setUint32(8, fileBytes.length, true)
// 压缩后的大小
dataDescriptorDataView.setUint32(12, fileBytes.length, true)
至此,一个文件在zip中所有的信息就已经都可以生成了,接下来就需要生成中央目录信息了
4. 中央目录区生成
根据上面的图,我们知道, 中央目录区也是由一个一个的文件头组成,每一个文件头对对应着一个真实文件的信息,每个文件信息大小是46 + n + m + k,其中n是文件名称的大小,m是扩展字段的大小,k是文件注释的大小。 在这里,我们可以暂时不必管扩展字段,先计算一下中央目录区的总大小:
// 假设有一个文件列表为flieList
const wholeLength = flieList.reduce((acc, file) => {
// 文件名长度
const nameBufferLength = textEncoder.encode(file.name).length
// 假设文件有注释字段comment
const commentBufferLength = textEncoder.encode(file.comment).length
// 累加起来
return acc + 46 + nameBufferLength + commentBufferLength
}, 0)
然后,创建一个变量存储中央目录区的数据
const centraHeader = new Uint8Array(wholeLength)
const centraHeaderDataView = new DataView(dataDescriptor.buffer)
接下来就可以通过循环,将所有文件的信息都写入中央目录区
// 假设有这样一个数据结构存储了文件的信息
type FileZipInfo = {
localFileHeader: Uint8Array
fileBytes: Uint8Array
dataDescriptor: Uint8Array
filename: string
fileComment: string
}
// offset表示中央目录信息中,当前文件相对于中央目录起始位置的偏移
// entryOffset 表示一个文件的信息(本地文件头+文件数据+数据描述符)相对于整个zip文件起始位置的偏移
let entryOffset = 0
for (
let i = 0, offset = 0;
i < fileZipInfoList.length;
i++
) {
const fileZipInfo = fileZipInfoList[i]
// 设置固定标识符号
centraHeaderDataView.setUint32(offset, 0x02014b50, true)
// 设置压缩版本号
centraHeaderDataView.setUint16(offset + 4, 0x0014, true)
// 因为中央目录信息中的文件数据一大部份都是本地文件头数据的冗余,所以可以直接复制过来使用
centraHeader.set(fileZipInfo.localFileHeader.slice(4, 30), offset + 6)
const textEncoder = new TextEncoder()
// 注释长度
const commentBuffer = textEncoder.encode(fileZipInfo.fileComment)
centraHeaderDataView.setUint16(offset + 32, commentBuffer.length, true)
// 对应的本地文件头在整个zip文件中的偏移
centraHeaderDataView.setUint32(offset + 42, entryOffset, true)
// 文件名
const filenameBuffer = textEncoder.encode(fileZipInfo.filename)
centraHeaderDataView.setUint16(filenameBuffer, offset + 46)
// 扩展字段暂时不管,下一个直接设置文件注释
bufferDataView.set(commentBuffer, offset + 46 + filenameBuffer.length)
// 更新offset的值
// 下一个中央目录中的文件的offset的值为此次生成的文件信息大小 + 当前的offset
// 也就是
offset = offset + 46 + commentBuffer.length + filenameBuffer.length
// entryOffset 的值累加为当前文件信息在整个zip文件中的大小 + 当前的 entryOffset
entryOffset += fileZipInfo.localFileHeader.length + fileZipInfo.fileBytes.length + fileZipInfo.dataDescriptor.length
}
最后,再生成 目录结束标识
// 目录结束标识的大小为22 + 注释信息(注释信息先忽略)
const eocdBytes = new Uint8Array(22)
const eocdDataView = new DataView(eocd.buffer)
// 固定标识值
eocdDataView.setUint32(eocdOffset, 0x06054b50, true)
// 和分卷有关的数据都可以忽略,他主要是为了处理一个zip文件跨磁盘存储的问题,现在基本没有这种场景
// 当前分卷号
eocdDataView.setUint16(4, 0, true)
// 中央目录开始分卷号
eocdDataView.setUint16(6, 0, true)
// 当前分卷的总文件数量
eocdDataView.setUint16(8, fileZipInfoList.length, true)
// 总文件数量
eocdDataView.setUint16(10, fileZipInfoList.length, true)
// 中央目录的总大小
eocdDataView.setUint32(12, wholeLength, true)
// 中央目录在整个zip文件中的目录偏移
eocdDataView.setUint32(16, entryOffset, true)
// 最后是注释的信息,先忽略
5. 拼接完整数据
完成了上面所有的步骤之后,我们只需要把数据都拼接起来就可以了
// 所有文件数据都存储在 fileZipInfoList中
// 组合文件数据
const fileBytesList = fileZipInfoList.map(fileZipInfo => {
return new Uint8Array([
...fileZipInfo.localFileHeader,
...fileZipInfo.fileBytes,
...fileZipInfo.dataDescriptor
])
})
const zipBlob = new Blob([
...fileBytesList,
centraHeader,
eocdBytes
],{
type: 'application/zip'
})
ok,搞定!
6. 完整的实现
三、总结
经过上面的步骤,我们就可以生成一个zip文件了,当然,这里只是一个简单的实现,zip文件格式还有很多细节,比如压缩算法、加密压缩等等,这里都没有涉及到,后面有时间再来完善吧。
参考资料:
来源:juejin.cn/post/7430660826900185097
用Flutter写可以,但架构可不能少啊
一个平台语言的开发优秀与否,取决于两个维度,一是语言的设计,这是语言天然的优劣,另一个测试程序员。
后者决定的东西太多太多了,如果后者对于某个平台类型的语言开发使用不当,那将导致非常严重的后果,屎山的形成、开发排期的无限增大、稳定性差到太平洋等等问题。
我之前写过一个Fluter的项目,但是写时Flutter还没有发布正式版本,到今天Flutter已经成为一棵参天大树,无数的同僚前辈已经用Flutter密谋生计。这两天看了一下相关的语法、技术, 决定对其进行二次熟悉。
从哪方面入手,成了我的第一个问题,看文档?记不住,看视频? 没时间,做项目?没需求(相关的)。所以决定探究一下开篇的问题,如何在新语言领域做好开发。
进来我一直在关注架构方面的技术,到没想着成为架构师(因为我太菜),只是想成为一个懂点架构的程序员,让自己的代码有良好的扩展性、维护性、可读性、健壮性,以此来洗涤自我心灵,让自己每天过的舒服点,因为好的代码看起来确实会让人心情愉悦,让领导喜笑颜开,让钱包增厚那么一奶奶。
一、 常见的Flutter 架构模式
其实还是老生常谈的几个问题,最终的目的就是: “高内聚,低耦合”,满足这个条件 让程序运行就可以了。
Fluter中常见的架构模式有以下几种:
- MVC(Model-View-Controller): 这是一种传统的软件设计架构,将应用程序分为模型(Model)、视图(View)和控制器(Controller)三个部分。在 Flutter 中,你可以使用类似于
StatefulWidget
、State
和其他 Dart 类来实现 MVC 架构。 - MVVM(Model-View-ViewModel): MVVM 是一种流行的设计模式,将视图(View)、模型(Model)和视图模型(ViewModel)分离。在 Flutter 中,你可以使用类似于
Provider
、GetX
、Riverpod
等状态管理库来实现 MVVM 架构。 - Bloc(Business Logic Component): Bloc 是一种基于事件驱动的架构,用于管理应用程序的业务逻辑和状态。它将应用程序分为视图、状态和事件三个部分,并使用流(Stream)来处理数据流。Flutter 官方推荐使用
flutter_bloc
库来实现 Bloc 架构。 - Redux: Redux 是一种状态管理模式,最初是为 Web 应用程序设计的,但也可以在 Flutter 中使用。它通过单一不可变的状态树来管理应用程序的状态,并使用纯函数来处理状态变化。在 Flutter 中,你可以使用
flutter_redux
或provider
与redux
库结合使用来实现 Redux 架构。 - GetX: GetX 是一个轻量级的、高性能的状态管理和路由导航库,它提供了一个全面的解决方案,包括状态管理、依赖注入、路由导航等。GetX 非常适合中小型 Flutter 应用程序的开发,可以减少代码量并提高开发效率。
当然MVP也不是不行。
对于Flutter来讲不仅有熟悉的MXXX, 还有几种新的模式。今天就先从最简单的MVC模式开始探究。
二、MVC架构实现Flutter开发
什么是MVC这里简单复习一下:
MVC(Model-View-Controller)是一种软件设计架构,用于将应用程序分为三个主要组件:模型(Model)、视图(View)和控制器(Controller)。这种架构的目的是将应用程序的逻辑部分与用户界面部分分离,以便于管理和维护。
以下是 MVC 架构中各组件的功能和作用:
- 模型(Model): 模型是应用程序的数据和业务逻辑部分。它负责管理数据的状态和行为,并提供对数据的操作接口。模型通常包括数据存储、数据验证、数据处理等功能。模型与视图和控制器相互独立,不直接与用户界面交互。
- 视图(View): 视图是应用程序的用户界面部分,负责向用户展示数据和接收用户输入。视图通常包括界面布局、样式设计、用户交互等功能。视图与模型和控制器相互独立,不直接与数据交互。
- 控制器(Controller): 控制器是模型和视图之间的中介,负责处理用户输入和更新模型数据。它接收用户的操作请求,并根据需要调用模型的方法来执行相应的业务逻辑,然后更新视图以反映数据的变化。控制器与模型和视图都有联系,但它们之间不直接通信。
在Flutter中 M无关紧要,只需要参与整个逻辑,让代码统一就可以了,封装一个对应的base,管理释放资源啊 公共数据也是可以的。
2.1 设计base
首先使用命令在Flutter项目中创建一个base, 创建时按照Flutter的工程类型做好组件的职责选择: Flutter工程中,通常有以下几种工程类型,下面分别简单概述下:
1. Flutter Application
标准的Flutter App工程,包含标准的Dart层与Native平台层
2. Flutter Module
Flutter组件工程,仅包含Dart层实现,Native平台层子工程为通过Flutter自动生成的隐藏工程
3. Flutter Plugin
Flutter平台插件工程,包含Dart层与Native平台层的实现
4. Flutter Package
Flutter纯Dart插件工程,仅包含Dart层的实现,往往定义一些公共Widget
很明显 我们需要的base 创建为package 即可:
flutter create -t package base
然后在项目的pubspec.yaml 中的
dependencies:
flutter:
sdk: flutter
base: //此处添加配置
path: ../base
- base 结构
View部分按照Flutter的常用开发模式(可变状态组件)设计为state + view 组合成View
他们的关系如下图:
代码:
2.1.1 base 代码
- model;
abstract class MvcBaseModel {
void dispose();
}
- controller
abstract class MvcBaseController {
late M _model;
final _dataUpdatedController = StreamController.broadcast();
MvcBaseController() {
_model = createModel();
}
void updateData(M model) {
_dataUpdatedController.add(model);
}
M createModel();
StreamController get streamController => _dataUpdatedController;
M get model => _model;
}
- view. (view)
abstract class MvcBaseView extends MvcBaseController> extends StatefulWidget {
final C controller;
const MvcBaseView({Key? key, required this.controller});
@override
State<StatefulWidget> createState() {
print("create state ${controller.streamController == null}");
MvcBaseState mvcBaseState = create();
mvcBaseState.createStreamController(controller.streamController);
return mvcBaseState;
}
MvcBaseState create();
}
- view(state)
abstract class MvcBaseState extends MvcBaseModel, T extends StatefulWidget>
extends State<T> {
late StreamController<M> streamController;
late StreamSubscription<M> _streamSubscription;
@override
Widget build(BuildContext context);
@override
void initState() {
super.initState();
print("init state");
_streamSubscription = this.streamController.stream.listen((event) {
setState(() {
observer(event);
});
});
}
void createStreamController(StreamController<M> streamController) => this.streamController = streamController;
void observer(M event);
@override
void dispose() {
_streamSubscription.cancel();
streamController.close();
super.dispose();
}
}
三、使用Demo
- 入口:
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: CounterView(controller: CounterController()),
);
}
}
- view + state
class CounterView extends MvcBaseView<CounterController> {
const CounterView({super.key,required CounterController controller})
: super(controller: controller);
@override
MvcBaseState<MvcBaseModel, StatefulWidget> create() => _CounterViewState();
}
class _CounterViewState extends MvcBaseState<CounterModel, CounterView> {
var count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Counter App (MVC)'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Counter Value = :',
style: TextStyle(fontSize: 20),
),
Text(
'${count}',
style: const TextStyle(fontSize: 50, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
debugPrint("---11-->");
// setState(() {
widget.controller.incrementCounter();
// });
},
child: Text('Test Add111'))
],
),
),
);
}
@override
void observer(CounterModel event) {
count = event.counter;
}
}
- model
class CounterModel extends MvcBaseModel{
int _counter = 0;
int get counter => _counter;
increment() {
_counter++;
}
@override
void dispose() {
}
}
- controller
class CounterController extends MvcBaseController {
@override
CounterModel createModel() => CounterModel();
void incrementCounter() {
model.increment();
updateData(model);
}
int get counter => model.counter;
}
四: 总结
通过实现基于MVC架构的Flutter应用程序,我们可以看到以下几点:
- 模型(Model)的作用: 模型负责管理应用程序的数据状态和行为。在我们的示例中,CounterModel负责管理计数器的数值状态,并提供了增加计数器数值的方法。
- 控制器(Controller)的作用: 控制器是模型和视图之间的中介,负责处理用户输入并更新模型数据。在示例中,CounterController接收用户点击事件,并调用CounterModel的方法来增加计数器数值,然后通知视图更新数据。
- 视图(View)的作用: 视图是应用程序的用户界面部分,负责向用户展示数据和接收用户输入。在示例中,CounterView负责展示计数器的数值,并提供了一个按钮来触发增加计数器数值的操作。
- MVC架构的优势: MVC架构能够将应用程序的逻辑部分与用户界面部分分离,使得代码结构更清晰,易于维护和扩展。通过单独管理模型、视图和控制器,我们可以更好地组织代码,并实现高内聚、低耦合的设计原则。
- 基础组件的设计: 我们设计了一个基础组件库,包括模型(MvcBaseModel)、控制器(MvcBaseController)、视图(MvcBaseView)和视图状态(MvcBaseState)。这些基础组件可以帮助我们快速构建符合MVC架构的Flutter应用程序,并实现模块化、可复用的代码结构。
通过理解和应用MVC架构,我们可以更好地组织和管理Flutter应用程序的代码,提高代码质量和开发效率。同时,我们也可以通过学习和探索其他架构模式,如MVVM、Bloc、Redux等,来丰富我们的架构设计思路,进一步提升应用程序的性能和用户体验。
后续将探索MVVM等其他架构模式。
来源:juejin.cn/post/7366557738266558498
决定了,做一个纯前端的pptx预览库
大家好,我是前端林叔。
今年我github的vue-office文档预览库star已经达到了3600+,不过这个库没什么技术含量,只不过是站在前人的肩膀上简单封装了下。目前该库包含了word(docx)、excel(xls、xlsx)和pdf的预览,唯独缺少ppt文档的预览,很多朋友都提过,能不能做一个ppt的预览库,我一直也在纠结。
为什么迟迟不做ppt的预览库
说到底,还是收益的问题,我做这件事的收益到底是什么?
一般来说做一个开源库我们会有以下几个收益:
- 证明自己的技术实力,在找工作时增加自己的竞争力(回答面试官经常问的那个问题,怎么证明你的技术深度?)
- 锻炼自己的技术能力,做一个好的开源项目需要一定的技术功底,在实战中提升自己是最快的方式
- 反哺开源社区,用爱发电,提升社区知名度
- 做得好了还可以考虑商业化赚钱
我迟迟没有做这件事就是没有想好我到底要什么,而且今年一直在忙着写掘金小册,也确实没有时间,另外就是在做vue-office库的时候,真切的感觉到,用爱发电是不长久的,如果没有利益驱动,是很难坚持下去的,试问,在如今行情这么不好的情况下,怎么平衡工作和自己的业余爱好,每个周末都去免费解决用户的问题,谁能长久地坚持下去呢?
为什么又决定做了
最近正好小册已经完结了(估计最近就会上线),自己也闲下来了,突然感觉失去了方向,不知道做啥了,整个人都变得迷茫,而且能预期到明年裁员的大刀就要砍到自己头上了,也要为后面的面试做下准备了,毕竟年龄大了,没有拿得出手的技术作品,想必后面也是很难的,把近期想做的事情排了个优先级,觉得这个事情还是比较重要的,于是决定开干!
但对于选择开源还是闭源纠结了很久,开源的话比较容易积累star,但主要还是精神支持,对长期利益来看是好的;不过开源后代码很容易被人拷贝改做他用,将自己辛辛苦苦几个月的成果免费拿走,还是不太甘心(这里忏悔下自己的格局)。我最终决定还是闭源,打赏一定金额(比如50以上)可以索取源码,源码不得用于开源,仅做学习和自己项目使用,后期可以考虑开发企业版,通过license授权。
这么做肯定会被人骂的,不过没办法,免费的事情实在坚持不下去了。当然了,只是不免费开放源码,使用都是免费的,会把最终的库发布到npm。
可行性
对于pptx格式的文件,实际上可以看做一个压缩文件,我们把任意一个pptx文件的后缀改为zip,然后解压,就可以看到pptx文件的内容,大部分都是xml文件,我们可以通过分析这个xml中的内容来获取ppt文档的信息,文档符合Microsoft Open XML(简称OOXML)规范。而对于.ppt格式的文件则无法获取其具体格式,所以本库只支持.pptx格式的文件。
说起来容易,不过由于xml的格式比较晦涩难懂,分析过程还是非常痛苦的,下面是ppt中单个幻灯片的xml,可以体会下其中的复杂度。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:bg>
<p:bgPr>
<a:solidFill>
<a:schemeClr val="accent2">
<a:alpha val="34902"/>
</a:schemeClr>
</a:solidFill>
<a:effectLst/>
</p:bgPr>
</p:bg>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:pic>
<p:nvPicPr>
<p:cNvPr id="2" name="图片 1">
<a:extLst>
<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
<a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
id="{92992223-295D-7122-C034-29375CD12672}"/>
</a:ext>
</a:extLst>
</p:cNvPr>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="rId2"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="1270000" y="635000"/>
<a:ext cx="1485900" cy="787400"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>
<p:pic>
<p:nvPicPr>
<p:cNvPr id="5" name="图片 4">
<a:extLst>
<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
<a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
id="{D071BB10-9D98-FEF0-768B-8E826152F476}"/>
</a:ext>
</a:extLst>
</p:cNvPr>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill rotWithShape="1">
<a:blip r:embed="rId3"/>
<a:srcRect r="46000"/>
<a:stretch/>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="685800" cy="1270000"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="矩形 2">
<a:extLst>
<a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
<a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
id="{FC6BFD96-7710-5D5C-0E6D-5647BB89F8D0}"/>
</a:ext>
</a:extLst>
</p:cNvPr>
<p:cNvSpPr/>
<p:nvPr/>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="7002462" y="2264229"/>
<a:ext cx="3110366" cy="522514"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
<p:style>
<a:lnRef idx="2">
<a:schemeClr val="accent1">
<a:shade val="15000"/>
</a:schemeClr>
</a:lnRef>
<a:fillRef idx="1">
<a:schemeClr val="accent1"/>
</a:fillRef>
<a:effectRef idx="0">
<a:schemeClr val="accent1"/>
</a:effectRef>
<a:fontRef idx="minor">
<a:schemeClr val="lt1"/>
</a:fontRef>
</p:style>
<p:txBody>
<a:bodyPr rtlCol="0" anchor="ctr"/>
<a:lstStyle/>
<a:p>
<a:pPr algn="ctr"/>
<a:endParaRPr kumimoji="1" lang="zh-CN" altLang="en-US">
<a:ln>
<a:solidFill>
<a:srgbClr val="FF0000"/>
</a:solidFill>
</a:ln>
</a:endParaRPr>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
<p:extLst>
<p:ext uri="{BB962C8B-B14F-4D97-AF65-F5344CB8AC3E}">
<p14:creationId xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" val="760063892"/>
</p:ext>
</p:extLst>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping/>
</p:clrMapOvr>
</p:sld>
怎么做好这个库
就像我在我的掘金小册中说的那样,做前端开发,首先要做的就是设计,必须先编写设计文档,然后再开发,现在我也是这么做的。
第一步:分析pptx中每个xml的含义
第二步:整体架构设计
我把这个库分成了三层(我在小册中提到的分层思维)
- PPTX Reader层:负责读取pptx中的内容,将其转为便于理解的格式,也就是自己定义的PPTX的对象
- PPTX Render层:负责进行pptx单个幻灯片的渲染,入参为上一步得到的PPTX对象,不同的渲染方式实现不同的渲染对象,比如我们可以开发一个HtmlRender,将其渲染成为html格式,或者开发一个Canvas Render将其渲染成为Canvas,而不是写死,这样扩展性也更好一些(小册中提到的前端扩展方法)
- PPTX Preview层:负责整个pptx文件的预览,比如是采用左右翻页展示还是一下把pptx的幻灯片都展示出来,都由这个层来决定。
其中文件读取是非常复杂的,面对这种复杂的大型项目,必须考虑采用面向对象的方式来组织代码(也是小册中提到的),我将 PPTX Reader层细化为如下几个类。
- PPTX: pptx类,存储pptx文档的信息,比如缩略图,尺寸大小等信息
- Theme: 主题类,存储pptx的主题信息
- Slide: 单个幻灯片类,存储幻灯片信息
- PicNode:图片类,用它表示幻灯片中的一个图片
- ShapeNode:形状类,用它表示幻灯片中的一个一个形状
- Node:不同节点的基类
- ...
第三步:搭建代码仓库
这次决定还是采用monorepo方式组织代码,其中技术栈包括 turbo + ts + jest单测 + rollup打包 + eslint 等。
目前进展
目前正在开发 PPTX Reader 层的相关代码,争取元旦前完成PPTX中基础功能的预览,有什么心得和进展随时给大家同步。
感兴趣的同学可以关注我或者仓库,小册近期也要上线了,到时候大家多关注支持。
来源:juejin.cn/post/7418389059287908404
为什么使用fetch时,有两个await?
为什么使用fetch时,有两个await?
提问
// first await
let response = await fetch("/some-url")
// second await
let myObject = await response.json()
你以前在使用fetch时,见过这两个await对吗?
有没有思考过,这是为什么?
思考
我们在浏览器中使用异步编程来,处理需要时间才能完成的任务(也就是异步任务),这样我们就不会阻塞用户界面。
等待 fetch 是有道理的。因为我们最好不要阻止 UI!
但是,我们到底为什么需要呢 await response.json()
?
解析 JSON 应该不会花费很长时间。 事实上,我们经常调用 JSON.parse("{"key": "value"}")
,这是一个同步调用。 那么,为什么 response.json()
返回 promise 而不是我们真正想要的呢?
这是怎么回事?
摘自 MDN 关于 Fetch API 的文章
https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#concepts_and_usage
fetch() 方法接受一个强制性参数,即你想要获取的资源的路径。 它返回一个 Promise,该 Promise 解析为该请求的 Response — 只要服务器使用 Headers 响应 — 即使服务器响应是 HTTP 错误状态。
因此,fetch 会在必须完全接收 body 之前解析响应。
查看前面的代码:
let response = await fetch("/some-url")
// At this point,
// 1. the client has received the headers
// 2. the body is (probably) still making its way over.
let myObject = await response.json()
// At this point,
// 1. the client has received the body
// 2. the client has tried to parse the body
// as json with the goal of making a JavaScript object
很明显,在整个正文到达之前访问 headers 是多么有用。
根据状态代码或其中一个标头,我们可能会决定根本不读取正文。
而 body 在 headers 之后到达实际上是我们已经习惯的。这就是浏览器中一切的工作方式。
HTML 通过网络缓慢发送,图像、字体等也是如此。
我想我只是被方法的名称弄糊涂了: response.json() .
有一个简单的节点服务器来演示这一点。相关代码都在这里
YouTube视频演示在这
http://www.youtube.com/watch?v=Ki6…
来源:juejin.cn/post/7432269413405769762
性能对比:为什么 Set.has() 比 Array.includes() 更快?
在 JavaScript
开发中,检查某个元素是否存在于集合中是一个常见的操作。对于这个任务,我们通常会使用两种方法:Set.has()
和 Array.includes()
。尽管它们都能实现查找功能,但在性能上存在显著差异。今天我们就来探讨一下,为什么 Set.has()
通常比 Array.includes()
更快,特别是在查找大量元素时。
数据结构的差异:
Set
vsArray
首先,要理解性能差异,我们需要了解
Set
和Array
在JavaScript
中的底层实现原理。它们使用了不同的数据结构,这对查找操作的效率有着直接影响。
Set
:哈希表的魔力
Set
是一种集合数据结构,旨在存储唯一的值。JavaScript
中的Set
通常使用 哈希表 来实现。在哈希表中,每个元素都有一个唯一的哈希值,这个哈希值用于快速定位和访问该元素。这意味着,当我们使用Set.has()
来检查某个元素时,JS
引擎能够直接计算该元素的哈希值,从而迅速确定元素是否存在。查找操作的时间复杂度是O(1)
,即无论集合中有多少个元素,查找的时间几乎是恒定的。
Array
:顺序遍历
与
Set
不同,Array
是一种有序的列表结构,元素按插入顺序排列。在数组中查找元素时,Array.includes()
方法必须遍历数组的每一个元素,直到找到目标元素或确认元素不存在。这样,查找操作的时间复杂度是O(n)
,其中n
是数组中元素的个数。也就是说,随着数组中元素数量的增加,查找所需的时间将线性增长。
性能差异:什么时候该用哪个?
在实际开发中,我们通常会选择根据数据的特性来选择
Set.has()
或Array.includes()
。但是,理解它们的性能差异有助于我们做出更加明智的决策。
小型数据集
对于较小的集合,性能差异可能不那么明显。在这种情况下,无论是
Set.has()
还是Array.includes()
,都能以接近常数时间完成操作,因为数据集本身就很小。因此,在小数据集的情况下,开发者更关心的是易用性和代码的简洁性,而不是性能。
例如,以下是对小型数据集的查找操作:
// 小型数据集
const smallSet = new Set([1, 2, 3, 4, 5]);
console.log(smallSet.has(3)); // true
const smallArray = [1, 2, 3, 4, 5];
console.log(smallArray.includes(3)); // true
在这个示例中,
Set.has()
和Array.includes()
都能快速找到元素3
,两者的性能差异几乎不明显。
Set.has(Code 1)和 Array.includes(Code 2)代码性能分析。数据来源:CodePerf
大型数据集
当数据集变得更大时,
Set.has()
的优势变得尤为明显。如果我们使用Array.includes()
在一个包含上百万个元素的数组中查找一个目标元素,时间复杂度将变为O(n)
,查找时间会随着数组的大小而增长。
而
Set.has()
在面对大数据集时,性能依然保持在O(1)
,因为它利用了哈希表的高效查找特性。下面是两个在大数据集下性能对比的例子:
// 大型数据集
const largeArray = Array.from({ length: 1000000 }, (_, i) => i);
const largeSet = new Set(largeArray);
const valueToFind = 999999;
console.time("Set.has");
console.log(largeSet.has(valueToFind)); // true
console.timeEnd("Set.has");
console.time("Array.includes");
console.log(largeArray.includes(valueToFind)); // true
console.timeEnd("Array.includes");
在这个例子中,当数据集非常大时,
Set.has()
显示了明显的性能优势,而Array.includes()
的执行时间会随着数组的大小而显著增加。
Set.has(Code 1)和 Array.includes(Code 2)代码性能分析。数据来源:CodePerf
重复元素的影响
Set
本身就是一个集合,只允许存储唯一的元素,因此它天然会去除重复的元素。如果你在一个包含大量重复元素的数组中查找某个值,使用Set
可以提高性能。因为在将数组转换为Set
后,我们不必担心查找操作的冗余计算。
// 数组中有重复元素
const arrayWithDuplicates = [1, 2, 3, 1, 2, 3];
const uniqueSet = new Set(arrayWithDuplicates);
// 使用 Set 查找
console.log(uniqueSet.has(2)); // true
何时选择
Array.includes()
尽管
Set.has()
在查找时的性能更优,但这并不意味着Array.includes()
就没有用武之地。对于小型数据集、对顺序有要求或需要保留重复元素的场景,Array.includes()
仍然是一个非常合适的选择。例如,数组保持元素的插入顺序,或者你需要查找重复元素时,数组仍然是首选。
总结
Set.has()
性能较好,特别是在处理大型数据集时,其查找时间接近O(1)
。Array.includes()
在小型数据集或元素顺序敏感时可以正常工作,但随着数据量的增加,其时间复杂度为O(n)
。- 在需要频繁查找元素且数据量较大的情况下,建议使用
Set
。 - 对于较小数据集或有顺序要求的操作,
Array.includes()
仍然是一个合适的选择。 - 因为构造
Set
的过程本身就是遍历的过程,所以如果只用来查询一次的话,可以使用Array.includes()
。但如果需要频繁查询,则建议使用Set
,尤其是在处理较大的数据集时,性能优势更加明显。
通过理解这两种方法的性能差异,我们可以在编写
JavaScript
程序时更加高效地处理数据查找操作,选择合适的数据结构来提升应用的性能。
来源:juejin.cn/post/7433458585147342882
基于Flutter实现的小说阅读器——BITReader ,相信我你也可以变成光!

前言
最近感觉自己有点颓废,左思右想后觉得不能这样浪费时间,天天来摆烂。受到了群友的激励以及最近自己喜欢看小说。就想我能不能自己也做一款小说阅读器出来呢。在最开始的时候花了一段时间写了一个版本。当时用的是一个开源的接口,当我写好后使用了两天接口挂了我就只有大眼瞪小眼了。之后在 FlutterCandies里面咨询了群友,发现了一种使用外部提供书籍数据源的方法可以避免数据来源挂掉,说干就干vscode启动!
项目地址
项目介绍
当前功能包含:
- 源搜索:使用内置数据来源进行搜索数据(后续更新:用户可以自行导入来源进行源搜索
- 收藏书架
- 阅读历史记录
- 阅读设置:字号设置,字体颜色更改,自定义阅读背景(支持调色板自定义选择,支持image设置为背景
- 主题设置:支持九种颜色的主题样式
- 书籍详情:展示书籍信息以及章节目录等书籍信息
支持平台
平台 | 是否支持 |
---|---|
Android | ✅ |
IOS | ✅ |
Windows | ✅ |
MacOS | ✅ |
Web | ❌ |
Linux | ❌ |
项目截图





mac运行截图

windows运行截图

项目结构
lib
├── main.dart -- 入口
├── assets -- 本地资源生成
├── base -- 请求状态、页面状态
├── db -- 数据缓存
├── icons -- 图标
├── net -- 网络请求、网络状态
├── n_pages
├── detail -- 详情页
├── home -- 首页
├── search -- 全网搜索搜索页
├── history -- 历史记录
├── read -- 小说阅读
└── like -- 收藏书架
├── pages 已废弃⚠
├── home -- 首页
├── novel -- 小说阅读
├── search -- 全网搜索
├── category -- 小说分类
├── detail_novel -- 小说详情
├── book_novel -- 书架、站源
└── collect_novel -- 小说收藏
├── route -- 路由
└── theme -- 主题管理
└── themes -- 主题颜色-9种颜色
├── tools -- 工具类 、解析工具、日志、防抖。。。
└── widget -- 自定义组件、工具 、加载、状态、图片 等。。。。。。
阅读器主要包含的模块
- 阅读显示:文本解析,对文本进行展示处理
- 数据解析: 数据源的解析,以及数据来源的解析(目前只支持简单数据源格式解析、后续可能会更新更多格式解析
- 功能:阅读翻页样式、字号、背景、背景图、切换章节、收藏、历史记录、本地缓存等
阅读显示
阅读文本展示我用的是extended_text因为支持自定义效果很好。
实现的效果把文本中 “ ” 引用起来的文本自定义成我自己想要的效果样式。
class MateText extends SpecialText {
MateText(
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap, {
this.showAtBackground = false,
required this.start,
required this.color,
}) : super(flag, '”', textStyle, onTap: onTap);
static const String flag = '“';
final int start;
final Color color;
/// whether show background for @somebody
final bool showAtBackground;
@override
InlineSpan finishText() {
final TextStyle textStyle =
this.textStyle?.copyWith(color: color) ?? const TextStyle();
final String atText = toString();
return showAtBackground
? BackgroundTextSpan(
background: Paint()..color = Colors.blue.withOpacity(0.15),
text: atText,
actualText: atText,
start: start,
///caret can move int0 special text
deleteAll: true,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}))
: SpecialTextSpan(
text: atText,
actualText: atText,
start: start,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}));
}
}
class NovelSpecialTextSpanBuilder extends SpecialTextSpanBuilder {
NovelSpecialTextSpanBuilder({required this.color});
Color color;
set setColor(Color c) => color = c;
@override
SpecialText? createSpecialText(String flag,
{TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
int? index}) {
if (flag == '') {
return null;
} else if (isStart(flag, AtText.flag)) {
return AtText(
textStyle,
onTap,
start: index! - (AtText.flag.length - 1),
color: color,
);
} else if (isStart(flag, MateText.flag)) {
return MateText(
textStyle,
onTap,
start: index! - (MateText.flag.length - 1),
color: color,
);
}
// index is end index of start flag, so text start index should be index-(flag.length-1)
return null;
}
}
数据解析编码格式转换
首先数据是有不同的编码格式,否则我们直接展示可能会导致乱码问题。
先把数据给根据查找到的编码类型来做单独的处理转换。
/// 解析html数据 解码 不同编码
static String parseHtmlDecode(dynamic htmlData) {
String resultData = gbk.decode(htmlData);
final charset = ParseSourceRule.parseCharset(htmlData: resultData) ?? "gbk";
if (charset.toLowerCase() == "utf-8" || charset.toLowerCase() == "utf8") {
resultData = utf8.decode(htmlData);
}
return resultData;
}
static String? parseCharset({
required String htmlData,
}) {
Document document = parse(htmlData);
List<Element> metaTags = document.getElementsByTagName('meta').toList();
for (Element meta in metaTags) {
String? charset = meta.attributes['charset'];
String content = meta.attributes['content'] ??
""; //<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
if (charset != null) {
return charset;
}
List<String> parts = content.split(';');
for (String part in parts) {
part = part.trim();
if (part.startsWith('charset=')) {
return part.split('=').last.trim();
}
}
}
return null;
}
数据结构解析-代码太多只展示部分
Document document = parse(htmlData);
//
List<Element> rootNodes = [];
if (rootSelector != null && rootSelector.isNotEmpty) {
//
List<String> rootParts = rootSelector.split(RegExp(r'[@>]'));
String initialPart = rootParts[0].trim();
//
if (initialPart.startsWith('class.')) {
String className = initialPart.split('.')[1];
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('.')) {
String className = initialPart.substring(1);
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('#')) {
String idSelector = initialPart.substring(1);
rootNodes = document.querySelectorAll('#$idSelector').toList();
} else if (initialPart.startsWith('id.')) {
String idSelector = initialPart.split('.')[1];
var element = document.querySelector('#$idSelector');
if (element != null) {
rootNodes.add(element);
}
} else if (initialPart.contains(' ')) {
String idSelector = initialPart.replaceAll(' ', ">");
var element = document.querySelector(idSelector);
if (element != null) {
rootNodes.add(element);
}
} else {
rootNodes = document.getElementsByTagName(initialPart).toList();
}
存储工具类 - 部分代码
/// shared_preferences
class PreferencesDB {
PreferencesDB._();
static final PreferencesDB instance = PreferencesDB._();
SharedPreferencesAsync? _instance;
SharedPreferencesAsync get sps => _instance ??= SharedPreferencesAsync();
/*** APP相关 ***/
/// 主题外观模式
///
/// system(默认):跟随系统 light:普通 dark:深色
static const appThemeDarkMode = 'appThemeDarkMode';
/// 多主题模式
///
/// default(默认)
static const appMultipleThemesMode = 'appMultipleThemesMode';
/// 字体大小
///
///
static const fontSize = 'fontSize';
/// 字体粗细
static const fontWeight = 'fontWeight';
/// 设置-主题外观模式
Future<void> setAppThemeDarkMode(ThemeMode themeMode) async {
await sps.setString(appThemeDarkMode, themeMode.name);
}
/// 获取-主题外观模式
Future<ThemeMode> getAppThemeDarkMode() async {
final String themeDarkMode =
await sps.getString(appThemeDarkMode) ?? 'system';
return darkThemeMode(themeDarkMode);
}
/// 设置-多主题模式
Future<void> setMultipleThemesMode(String value) async {
await sps.setString(appMultipleThemesMode, value);
}
/// 获取-多主题模式
Future<String> getMultipleThemesMode() async {
return await sps.getString(appMultipleThemesMode) ?? 'default';
}
/// 获取-fontsize 大小 默认18
Future<double> getNovelFontSize() async {
return await sps.getDouble(fontSize) ?? 18;
}
/// 设置 -fontsize 大小
Future<void> setNovelFontSize(double size) async {
await sps.setDouble(fontSize, size);
}
/// 设置-多主题模式
Future<void> setNovelFontWeight(NovelReadFontWeightEnum value) async {
await sps.setString(fontWeight, value.id);
}
/// 获取-多主题模式
Future<String> getNovelFontWeight() async {
return await sps.getString(fontWeight) ?? 'w300';
}
}
最后
特别鸣谢FlutterCandies糖果社区,也欢迎加入我们的大家庭。让我们一起学习共同进步
免责声明:本项目提供的源代码仅用学习,请勿用于商业盈利。
来源:juejin.cn/post/7433306628994940979
对于 Flutter 快速开发框架的思考
要打造一个Flutter的快速开发框架,首先要思考的事情是一个快速开发框架需要照顾到哪些功能点,经过2天的思考,我大致整理了一下需要的能力:
- 状态管理:很明显全局状态管理是不可或缺的,这个在前端领域上,几乎是一种不容置疑的方案沉淀,他就像人体的血液循环系统,连接了每个区域角落。
- 网络请求管理:这个是标配了,对外的窗口,一般来讲做选型上需要注意可以支持请求拦截,支持响应拦截,以及错误处理机制,方便做重试等等。
- 路由管理:可以说很多项目路由混乱不堪,导致难以维护,和这个功能脱不了干系,一般来讲,需要支持到页面参数传递,路由守卫的能力。
- UI组件库:在Flutter上,可能不太需要考虑这个,因为Flutter本身自己就是已这个为利刃的行家了,不过现在有些企业发布了自己的UI库,觉得可以跟一下。
- 数据持久化:对于用户的一些设置,个性化配置,通常需要存在本地。而且,有时候,我们在做性能优化的时候,需要缓存网络请求到本地,以便,可以实现秒开页面,因此这依然也是一个不可获取的基础模块。
- 依赖注入:很多情况下,为了便于管理和使用应用中的服务和数据模型,我们需要这个高级能力,但是属于偏高级点的能力了,所以是一个optional的,你可以不考虑。
- 国际化:支持多语言开发,现在App一般都还是挺注重这块的,而且最好是立项的时候就考虑进来,为后续的出海做准备,因为这个越到后面,处理起来工作量越大。
- 测试框架:支持单元测试、组件测试和集成测试,保证业务质量,自动化发现问题。
- 调试工具:帮助开发者快速定位和解决问题,排查性能问题。
- CI/CD集成:支持持续集成和持续部署的解决方案,简化应用的构建、测试和发布过程。
那么,基于上面的分析,我就开始做了一些选型,这里基本上就是按照官方Flutter Favorites ,里面推荐的来选了。因为这些建议的库都是目前Flutter社区中比较流行和受欢迎的,能够提供稳定和高效的开发体验。
1. 状态管理:Riverpod
- 库名: flutter_riverpod
- 描述: 一个提供编译时安全、测试友好和易于组合的状态管理库。
- 选择理由: Riverpod 是 Provider 的升级版,提供更好的性能和灵活性,但是说哪个更好,其实不能一概而论,毕竟不同的人会有不同的编码习惯,当然这里可以设计得灵活一些,具体全局状态管理可以替换,即便你想使用 GetX,或者是 flutter_bloc 也是 OK 的。
@riverpod
Future boredSuggestion(BoredSuggestionRef ref) async {
final response = await http.get(
Uri.https('boredapi.com/api/activit…'),
);
final json = jsonDecode(response.body);
return json['activity']! as String;
}
class Home extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final boredSuggestion = ref.watch(boredSuggestionProvider);
// Perform a switch-case on the result to handle loading/error states
return boredSuggestion.when(
loading: () => Text('loading'),
error: (error, stackTrace) => Text('error: $error'),
data: (data) => Text(data),
);
}
}
2. 网络请求管理:Dio
- 库名: dio
- 描述: 一个强大的Dart HTTP客户端,支持拦截器、全局配置、FormData、请求取消等。
- 选择理由: Dio 支持Restful API、拦截器和全局配置,易于扩展和维护。这个已经是老牌的网络请求库了,稳定的很,且支持流式传输,访问大模型也丝毫不马虎。
final rs = await dio.get(
url,
options: Options(responseType: ResponseType.stream), // Set the response type tostream
.
);
print(rs.data.stream); // Response stream.
3. 路由管理:routemaster
- 库名: routemaster
- 描述: 提供声明式路由解决方案,支持参数传递、路由守卫等。
- 选择理由: url的方式访问,简化了路由管理的复杂度。
'/protected-route': (route) =>
canUserAccessPage()
? MaterialPage(child: ProtectedPage())
: Redirect('/no-access'),
4. UI组件库:tdesign_flutter
- 库名: tdesign_flutter
- 描述: 腾讯TDesign Flutter技术栈组件库,适合在移动端项目中使用。。
- 选择理由: 样式比原生的稍微好看且统一一些,大厂维护,减少一些在构建UI方面的复杂性。
5. 数据持久化:Hive
- 库名: hive
- 描述: 轻量级且高性能的键值对数据库。
- 选择理由: Hive 提供了高性能的读写操作,无需使用SQL即可存储对象。
var box = Hive.box('myBox');
box.put('name', 'David');
var name = box.get('name');
print('Name: $name');
6. 依赖注入:GetIt
- 库名: get_it
- 描述: 一个简单的服务注入,用于依赖注入。
- 选择理由: GetIt 提供了灵活的依赖注入方式,易于使用且性能高效。
final getIt = GetIt.instance;
void setup() {
getIt.registerSingleton(AppModel());
// Alternatively you could write it if you don't like global variables
GetIt.I.registerSingleton(AppModel());
}
MaterialButton(
child: Text("Update"),
onPressed: getIt().update // given that your AppModel has a method update
),
7. 国际化和本地化:flutter_localization
- 库名: flutter_localization
- 描述: Flutter官方提供的国际化和本地化支持。
- 选择理由: 官方支持,集成简单,覆盖多种语言。
8. 测试和调试:flutter_test, mockito
- 库名: flutter_test (内置), mockito
- 描述: flutter_test提供了丰富的测试功能,mockito用于模拟依赖。
- 选择理由: flutter_test是Flutter的官方测试库,mockito可以有效地模拟类和测试行为。
9. 日志系统:logger
- 库名: logger
- 描述: 提供简单而美观的日志输出。
- 选择理由: logger支持不同级别的日志,并且输出格式清晰、美观。
10. CI/CD集成
CI/CD集成通常涉及外部服务,如GitHub Actions、Codemagic等,而非Flutter库。
目录规划
前面已经做完了选型,下来我们可以确立一下我们快速开发框架的目录结构,我们给框架取名为fdflutter,顾名思义,就是fast development flutter,如下:
fdflutter/
├── lib/
│ ├── core/
│ │ ├── api/
│ │ │ └── api_service.dart
│ │ ├── di/
│ │ │ └── injection_container.dart
│ │ ├── localization/
│ │ │ └── localization_service.dart
│ │ ├── routing/
│ │ │ └── router.dart
│ │ └── utils/
│ │ └── logger.dart
│ ├── data/
│ │ ├── datasources/
│ │ │ ├── local_datasource.dart
│ │ │ └── remote_datasource.dart
│ │ └── repositories/
│ │ └── example_repository.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── example_entity.dart
│ │ └── usecases/
│ │ └── get_example_data.dart
│ ├── presentation/
│ │ ├── pages/
│ │ │ └── example_page.dart
│ │ └── providers/
│ │ └── example_provider.dart
│ └── main.dart
├── test/
│ ├── data/
│ ├── domain/
│ └── presentation/
├── pubspec.yaml
└── README.md
在这个结构中,我保持了核心功能、数据层、领域层和表示层的划分:
- core/api/: 使用Dio来实现ApiService,处理所有网络请求。
- core/di/: 使用GetIt来实现依赖注入,注册和获取依赖。
- core/localization/: 使用flutter_localization来实现本地化服务。
- core/routing/: 使用routemaster来实现路由管理。
- core/utils/: 使用logger来实现日志记录。
- data/: 数据层包含数据源和仓库,用于获取和管理数据。
- domain/: 领域层包含实体和用例,用于实现业务逻辑。
- presentation/: 表示层包含页面和Provider,用于显示UI和管理状态。
- test/: 测试目录包含各层的测试代码,使用flutter_test和mockito来编写测试。
我想,感兴趣的朋友们,可以私信我交流,我后续会在 GitHub 上放出该flutter 快速开发框架的 template 地址。
探索代码的无限可能,与老码小张一起开启技术之旅。点关注,未来已来,每一步深入都不孤单。
来源:juejin.cn/post/7340898858556964864
前端啊,拿Lottie炫个动画吧
点赞 + 关注 + 收藏 = 学会了
本文简介
有时候在网页上看到一些很炫酷的小动画,比如loading特效,还能控制这个动画的状态,真的觉得很神奇。
大部分做后端的不想碰前端,做前端的不想碰动画特效。
其实啊,很多时候不需要自己写炫酷的特效,会调用第三方库已经挺厉害的了。比如今天要介绍的 Lottie。
Lottie 是什么?
🔗Lottie官网 airbnb.io/lottie/
Lottie 是一个适用于 Android、iOS、Web 和 Windows 的库,它可以解析使用 Bodymovin 导出为 JSON 的 Adobe After Effects 动画,并在移动设备和 Web 上本地渲染它们!
After Effects 是什么?Bodymovin 又是什么?
别怕,这些我也不会。作为前端,我会拿别人做好的东西来用😁
简单来说,Lottie 是 Airbnb 开发的动画库,特别适合前端开发人员。它可以轻松实现复杂的动画效果,不需要手写大量代码,只需引入现成的 JSON 文件即可。
今天不讲iOS,不讲Android,只讲如何在前端使用 Lottie。
安装 Lottie Web
要在前端项目中使用 Lottie,要么用 CDN 的方式引入,要么通过 NPM 下载。
CDN
在这个网址可以找到 Lottie 的各个版本的JS文件: cdnjs.com/libraries/b…
我使用的是 5.12.2 这个版本
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#lottie {
width: 200px;
height: 200px;
}
</style>
</head>
<body>
<div id="lottie"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
<script>
var animation = lottie.loadAnimation({
container: document.getElementById('lottie'), // 渲染动画的容器
renderer: 'svg', // 渲染方式
loop: true, // 是否循环
autoplay: true, // 是否自动播放
path: './Animation_1.json' // 动画 JSON 文件的路径
});
</script>
</body>
</html>
Animation_1.json
是我下载的一个动画文件,这个文件我放在同级目录里。这个动画文件在哪可以下载我接下来会介绍。这里先了解一下 CDN 的方式怎么引入 Lottie 即可。
NPM
用下面这个命令将 Lottie 下载到你的项目里。
npm install lottie-web
动画资源下载
前面介绍到,动画是用 AE 做好,然后用 Bodymovin 插件将动画转换成一个 JSON 文件,前端就可以使用 lottie-web 将这个 JSON 文件的内容转换成图像渲染到浏览器页面上。
如果想要现成的动画资源可以在这些地方找找
- lottiefiles:lottiefiles.com/
- iconfont的lottie模块:http://www.iconfont.cn/lotties/ind…
- Creattie:creattie.com/
- Lottielab(自己编辑、下载):http://www.lottielab.com/
我这里也给大家准备了一个动画文件,大家可以拿它来练手。
- 【百度网盘】链接: pan.baidu.com/s/1Qnp3BAAT… 提取码: d7gt
- 【阿里云盘】链接:http://www.alipan.com/s/sfMVak2Xh… 提取码:35kw
实现第一个 Lottie 动画
我通过 React
脚手架创建了一个 React
项目来举例说明如何使用 Lottie,在 Vue
里的用法也是一样的。
import React, { useEffect, useRef } from 'react';
import lottie from 'lottie-web';
import animationData from './assets/animations/Animation.json';
function App() {
const containerRef = useRef(null);
useEffect(() => {
const anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData
});
}, []);
return <div ref={containerRef} style={{width: "300px", height: "300px"}}></div>;
}
export default App;
在 HTML 文件中,创建一个容器,用于放置 Lottie 动画。在这个例子中我创建了一个宽和高都是 300px
的 div
元素。
然后引入 lottie-web
以及放在前端项目里的 Animation.json
动画文件。
最后调用 lottie.loadAnimation()
来启动动画。它将一个对象作为唯一参数。
container
:动画容器,这个例子通过React
提供的语法获取到DOM
元素。renderer
:渲染方式,可选svg
、canvas
和html
。loop
:是否循环播放。autoplay
:是否自动播放。animationData
:本地的动画数据的对象。
这里需要注意,animationData
接收的动画对象是存放在前端项目的 JSON
文件,如果你的动画文件是存在别的服务器,需要通过一个 URL
引入的话就不能用 animationData
来接收了,而是要改成 path
。
const anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});
Lottie 常用功能
播放、暂停、停止
控制动画的播放、暂停、停止是很常用的功能。
- 播放:使用
play()
方法。顾名思义就是让动画动起来。 - 暂停:使用
pause()
方法。暂停可以让动画在当前帧停下来。可以这么理解,你在看视频一个10秒的短视频,播放到第7秒的时候你按了“暂停”,画面就停在第7秒的地方了。 - 停止:使用
stop()
方法。停止和暂停都是让动画停下来,而停止会让动画返回第1帧画面的地方停下来。
import lottie from 'lottie-web';
import React, { useEffect, useRef } from 'react';
import animationData from './assets/animations/Animation.json';
function App() {
const containerRef = useRef(null);
let anim = null
useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData,
});
}, []);
// 播放动画
function play() {
anim.play()
}
// 暂停动画
function pause() {
anim.pause()
}
// 停止动画
function stop() {
anim.stop()
}
return <>
<div ref={containerRef} style={{width: "300px", height: "300px"}}></div>
<button onClick={play}>播放</button>
<button onClick={pause}>暂停</button>
<button onClick={stop}>停止</button>
</>;
}
export default App;
代码放这,建议自己运行起来体验一下。
设置动画播放速度
使用 setSpeed()
方法可以设置动画的播放速度,传入一个数字即可。默认的播放速度是1。
// 省略部分代码
// 2倍速度播放
anim.setSpeed(2)
这个参数支持正数(包括非整数)、0、负数。
- 大于1的正数:比默认速度快
- 大于0小于1:比默认速度慢
- 0:画面停止在第一帧不动了
- 小于0大于-1:动画倒放,而且速度比默认值慢
- -1:动画倒放,速度和默认值一样
- 小于-1:动画倒放,速度比默认值快
设置动画播放方向
这里说的播放方向指的是「正着放」还是「倒着放」。前面用 setSpeed()
方法可以做到这点。但还有一个叫 setDirection()
的方法也能做到。
setDirection()
接收一个数字参数,这个参数大于等于0时是正着播放,负数时是倒着播放。通常情况下,想倒着播放会传入 -1。
// 省略部分代码
anim.setDirection(-1)
看,面是吐出来的。
设置动画进度
通过 goToAndStop()
方法可以控制动画跳转到指定帧或时间并停止。
goToAndStop(value, isFrame)
接收2个参数。
value
:数值,表示要跳转到的帧数或时间点。isFrame
:布尔值,默认为false
。如果设置为true
,则value
参数表示帧数;如果设置为false
,则value
参数表示时间(以毫秒为单位)。
function goToAndStop() {
anim.goToAndStop(1000, false)
}
return <>
<div ref={containerRef} style={{width: "300px", height: "300px"}}></div>
<button onClick={goToAndStop}>跳转到1秒</button>
</>;
如果 goToAndStop
第二个参数为 true
则表示要跳转到指定帧数,这个值不能超过动画的总帧数。
销毁动画实例
有些场景在某个时刻需要将动画元素删除掉,比如在数据加载时需要显示 loading,数据加载成功或者失败后需要隐藏 loading,此时可以用 destroy
将 Lottie 动画实例销毁掉。
// 省略部分代码
anim.destroy()
动画监听事件
动画有很多个状态,比如动画数据加载完成/失败、动画播放结束、循环下一次播放、进入新的一帧。Lottie 为我们提供了几个常用的监听方法。
而要监听这些事件,需要在 lottie
实例上用 addEventListener
方法绑定各个事件。
动画数据加载情况
监听动画数据(JSON文件)加载成功或者失败,可以用这两个方法。
data_ready
:数据加载成功后执行。data_failed
:数据加载失败后执行。
需要注意,这两个方法只适用 path
的方式加载数据时触发。animationData
加载的是本地数据,并不会触发这两个方法。
// 省略部分代码
let anim = null;
useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});
anim.addEventListener('data_ready', () => {
console.log('数据加载完成');
});
anim.addEventListener('data_failed', () => {
console.log('数据加载失败');
})
}, []);
初始配置完成后
在数据加载前,还可以通过 config_ready
监听初始化配置的完成情况。
要让 config_ready
生效,同样需要通过 path
的方式加载数据。
config_ready
的执行顺序排在 data_ready
之前。
// 省略部分代码
let anim = null;
useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});
anim.addEventListener('data_ready', () => {
console.log('数据加载完成');
});
anim.addEventListener('config_ready', () => {
console.log('初始化成功');
});
}, []);
动画播放结束
当动画播放结束时,会触发 complete
事件。
如果 loop
为 true
的话时不会触发 complete
的,因为一直循环的话动画是没有结束的那天。
// 省略部分代码
let anim = null;
useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: false,
autoplay: true,
animationData: animationData,
});
anim.addEventListener('complete', () => {
console.log('动画播完了');
});
}, []);
动画循环播放结束
当 loop
为 true
时,每循环播放完一次就会触发 loopComplete
事件。
// 省略部分代码
let anim = null;
useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData,
});
anim.addEventListener('loopComplete', () => {
console.log('循环结束,准备进入下一次循环');
});
}, []);
当你通过 pause()
暂停了动画,过一阵用 play()
继续播放,也会等这次动画完整播放完才会触发 loopComplete
。
进入新的一帧
一个动画由很多个画面组成,每个画面都属于1帧。动画每进入一帧时都会触发 enterFrame
事件。
// 省略部分代码
let anim = null;
useEffect(() => {
anim = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg',
loop: true,
autoplay: true,
animationData: animationData,
// path: 'https://lottie.host/68bd36a3-b21d-4909-9b61-9be6b0947943/gInO8owFG1.json'
});
anim.addEventListener('enterFrame', () => {
console.log('进入新帧');
});
}, []);
自己手写一个动画JSON?
手写 Lottie 的 JSON 动画文件相对复杂,因为需要对 Lottie 的 JSON 结构有较深入的理解。Lottie 的 JSON 文件基于 Bodymovin 插件输出的格式,主要包含静态资源、图层、形状以及帧动画信息。
由于相对复杂,所以不建议真的自己手写,这会显得你很傻。
Lottie JSON 文件由多个部分组成,主要包括:
assets
:动画中使用的资源(图片等)。layers
:动画中的每一层(类似于 Photoshop 图层)。shapes
:定义图形、路径等基本元素及其动画。animations
:定义每一帧的动画数据,包括位置、缩放、透明度等。
太复杂的元素我确实手写不出来,只能写一个简单的圆形从左向右移动演示一下。
{
"v": "5.6.10", // Lottie 版本
"fr": 30, // 帧率 (Frames per second)
"ip": 0, // 动画开始帧 (In Point)
"op": 60, // 动画结束帧 (Out Point)
"w": 500, // 画布宽度
"h": 500, // 画布高度
"nm": "circle animation",// 动画名称
"ddd": 0, // 是否是 3D 动画
"assets": [], // 静态资源(如图片等)
"layers": [ // 动画的图层
{
"ddd": 0, // 图层是否是 3D
"ind": 1, // 图层索引
"ty": 4, // 图层类型,4 代表形状图层
"nm": "circle", // 图层名称
"sr": 1, // 图层的播放速度
"ks": { // 图层的关键帧属性(动画数据)
"o": { // 不透明度动画
"a": 0, // 不透明度动画为 0,表示不设置动画
"k": 100 // 不透明度固定为 100%
},
"r": { // 旋转动画
"a": 0, // 不设置动画
"k": 0 // 旋转角度为 0
},
"p": { // 位置动画 (Position)
"a": 1, // a 为 1 表示位置有动画
"k": [
{
"i": { "x": 0.667, "y": 1 }, // 起始位置插值
"o": { "x": 0.333, "y": 0 }, // 终止位置插值
"n": "0p667_1_0p333_0", // 插值模式名称
"t": 0, // 起始帧
"s": [50, 250, 0], // 起始位置 (x: 50, y: 250)
"e": [450, 250, 0], // 结束位置 (x: 450, y: 250)
"to": [66.66667, 0, 0], // 起始插值控制点
"ti": [-66.66667, 0, 0] // 终止插值控制点
},
{ "t": 60 } // 在 60 帧时结束动画
]
},
"a": { // 锚点动画(用于旋转或缩放中心)
"a": 0,
"k": [0, 0, 0] // 锚点固定在 (0, 0)
},
"s": { // 缩放动画 (Scale)
"a": 0,
"k": [100, 100, 100] // 保持 100% 缩放
}
},
"ao": 0, // 自动定向
"shapes": [ // 图形数组,定义图层中的形状
{
"ty": "el", // 图形类型 'el' 代表 ellipse(椭圆/圆形)
"p": { // 椭圆的中心点
"a": 0,
"k": [0, 0]
},
"s": { // 椭圆的大小
"a": 0,
"k": [100, 100] // 圆的宽和高为 100px
},
"nm": "ellipse"
},
{
"ty": "st", // 图形类型 'st' 代表 stroke(描边)
"c": { // 描边颜色
"a": 0,
"k": [1, 0, 0, 1] // 红色 [R: 1, G: 0, B: 0, Alpha: 1]
},
"o": { // 描边不透明度
"a": 0,
"k": 100
},
"w": { // 描边宽度
"a": 0,
"k": 10
},
"lc": 1, // 线帽样式
"lj": 1, // 线接样式
"ml": 4 // 折线限制
}
],
"ip": 0, // 图层开始帧
"op": 60, // 图层结束帧
"st": 0, // 图层起始时间
"bm": 0 // 混合模式
}
]
}
v
: 表示 Lottie 动画的版本。fr
: 帧率,表示每秒多少帧。在这个示例中,每秒播放 30 帧。ip
和op
: 分别代表动画的起始帧和结束帧。本例中,动画从第 0 帧开始,到第 60 帧结束。layers
: 图层数组。每个图层包含ks
(关键帧属性),用于控制位置、缩放、旋转等动画参数。
ty: 4
: 图层类型为形状图层。p
: 定义了位置动画,从帧 0 开始,圆形从 (50, 250) 移动到 (450, 250) 的位置,表示从画布左侧移动到右侧。
shapes
: 定义了图形的属性。
el
: 表示一个椭圆形,即我们定义的圆形。st
: 表示圆形的描边,颜色为红色,宽度为 10px。
以上就是本文的全部内容,如果本文对你有帮助,欢迎转发给你的朋友。
点赞 + 关注 + 收藏 = 学会了
来源:juejin.cn/post/7430690608711647232
什么年代了?还不懂为什么一定要在团队项目开发中去使用 TypeScript ?
为什么要去使用 TypeScript ?
一直以来 TypeScript
的存在都备受争议,很多人认为他加重了前端开发的负担,特别是在它的严格类型系统和 JavaScript
的灵活性之间的矛盾上引发了不少讨论。
支持者认为 TypeScript
提供了强类型检查、丰富的 IDE 支持和更好的代码重构能力,从而提高了大型项目的代码质量和可维护性。
然而,也有很多开发者认为 TypeScript
加重了开发负担,带来了不必要的复杂性,尤其是在小型项目或快速开发场景中,它的严格类型系统可能显得过于繁琐,限制了 JavaScript
本身的动态和自由特性
但是随着项目规模的增大和团队协作的复杂性增加,TypeScript
的优势也更加明显。因为你不可能指望团队中所有人的知识层次和开发习惯都达到同一水准!你也不可能保证团队中的其他人都能够完全正确的使用你封装的组件、函数!
在大型项目中我们往往会封装到很多工具函数、组件等等,我们不可能在使用到组件时跑去看这个组件的实现逻辑,而
TypeScript
的类型提示正好弥补了这一点。通过明确的类型注解,TypeScript
可以在代码中直接提示每个组件的输入输出、参数类型和预期结果,让开发者只需在 IDE 中悬停或查看提示信息,就能了解组件的用途和使用方式,而不需要翻阅具体实现逻辑。
这时你可能会说,使用
JSDoc
也能够实现类似的效果。的确,JSDoc
可以通过注释的形式对函数、参数、返回值等信息进行详细描述,甚至可以生成文档。
然而,
JSDoc
依赖于开发者的自觉维护,且其检查和提示能力远不如TypeScript
强大和全面。TypeScript
的类型系统是在编译阶段强制执行的,这意味着所有类型定义都是真正的 “硬性约束”,能在代码运行前捕获错误,而不仅仅是提示。
在实际开发中,
JSDoc
的确能让我们知道参数类型,但它只是一种 “约定” ,而不是真正的约束。这意味着,如果同事在使用工具函数时不小心写错了类型,比如传了字符串而不是数字,JSDoc
只能通过注释告诉你正确的使用方法,却无法在你出错时立即给出警告。
然而在
TypeScript
中,类型系统会在代码编写阶段实时检查。比如,你定义的函数要求传入数字类型的参数,如果有人传入了字符串,IDE 立刻会报错提醒你,防止错误进一步传播。
所以,TypeScript 的价值就在于它提供了一层代码保护,让代码有了“硬约束”,团队在开发过程中更加节省心智负担,显著提升开发体验和生产力,少出错、更高效。
接下来我们来使用 TypeScript
写一个基础的防抖函数作为示例。通过类型定义和参数注解,我们不仅能让防抖函数更加通用且类型安全,还能充分利用 TypeScript
的类型检查优势,从而提高代码的可读性和可维护性。
这样的实现方式将有效地降低潜在的运行时错误,特别是在大型项目中,可以使团队成员之间的协作能够更加顺畅,并且避免一些低级问题。
功能点讲解
防抖函数的主要功能是:在指定的延迟时间内,如果函数多次调用,只有最后一次调用会生效。这一功能尤其适合优化用户输入等高频事件。
防抖函数的核心功能
- 函数执行的延迟控制:函数调用后不立即执行,而是等待一段时间。如果在等待期间再次调用函数,之前的等待会被取消,重新计时。
- 立即执行选项:有时我们希望函数在第一次调用时立即执行,然后在延迟时间内避免再次调用。
- 取消功能:我们还希望在某些情况下手动取消延迟执行的函数,比如当页面卸载或需要重新初始化时。
第一步:编写函数框架
在开始封装防抖函数之前,我们首先应该想到的就是要写一个函数,假设这个函数名叫 debounce
。我们先创建它的基本框架:
function debounce() {
// 函数的逻辑将在这里编写
}
这一步非常简单,先定义一个空函数,这个函数就是我们的防抖函数。在后续步骤中,我们会逐步向这个函数中添加功能。
第二步:添加基本的参数
防抖函数的第一个功能是控制某个函数的执行,因此,我们需要传递一个需要防抖的函数。其次,防抖功能依赖于一个延迟时间,这意味着我们还需要添加一个用于设置延迟的参数。
让我们扩展一下 debounce
函数,为它添加两个基本的参数:
func
:需要防抖的目标函数。duration
:防抖的延迟时间,单位是毫秒。
function debounce(func: Function, duration: number) {
// 函数的逻辑将在这里编写
}
func
是需要防抖的函数。每当防抖函数被调用时,我们实际上是在控制这个func
函数的执行。duration
是延迟时间。这个参数控制了在多长时间后执行目标函数。
第三步:为防抖功能引入定时器逻辑
防抖的核心逻辑就是通过定时器(setTimeout
),让函数执行延后。那么我们需要用一个变量来保存这个定时器,以便在函数多次调用时可以取消之前的定时器。
function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
}
let timer: ReturnType<typeof setTimeout> | null = null
:我们使用一个变量timer
来存储定时器的返回值。clearTimeout(timer)
:每次调用防抖函数时,都会清除之前的定时器,这样就保证了函数不会被立即执行,直到等待时间结束。setTimeout
:在指定的延迟时间后执行传入的目标函数func
,并传递原始参数。
为什么写成了 ReturnType<typeof setTimeout> | null
这样的类型 ?
在 JavaScript
中,setTimeout
是一个内置函数,用来设置一个延迟执行的任务。它的基本语法如下:
let id = setTimeout(() => {
console.log("Hello, world!");
}, 1000);
setTimeout
返回一个定时器 ID(在浏览器中是一个数字),这个 ID 用来唯一标识这个定时器。如果你想取消定时器,你可以使用 clearTimeout(id)
,其中 id
就是这个返回的定时器 ID。
ReturnType<T>
是 TypeScript 提供的一个工具类型,它的作用是帮助我们获取某个函数类型的返回值类型。我们通过泛型T
来传入一个函数类型,然后ReturnType<T>
就会返回这个函数的返回值类型。在这里我们可以用它来获取setTimeout
函数的返回类型。
为什么需要使用 ReturnType<typeof setTimeout>
?
由于不同的 JavaScript
运行环境中,setTimeout
的返回值类型是不同的:
- 在浏览器中,
setTimeout
返回的是一个数字 ID。 - 在Node.js 中,
setTimeout
返回的是一个对象(Timeout
对象)。
为了兼容不同的环境,我们需要用 ReturnType<typeof setTimeout>
来动态获取 setTimeout
返回的类型,而不是手动指定类型(比如 number
或 Timeout
)。
let timer: ReturnType<typeof setTimeout>;
这里 ReturnType<typeof setTimeout>
表示我们根据 setTimeout
的返回值类型自动推导出变量 timer
的类型,不管是数字(浏览器)还是对象(Node.js),TypeScript 会自动处理。
为什么需要设置联合类型 | null
?
在我们的防抖函数实现中,定时器 timer
并不是一开始就设置好的。我们需要在每次调用防抖函数时动态设置定时器,所以初始状态下,timer
的值应该是 null
。
使用 | null
表示联合类型,它允许 timer
变量既可以是 setTimeout
返回的值,也可以是 null
,表示目前还没有设置定时器。
let timer: ReturnType<typeof setTimeout> | null = null;
ReturnType<typeof setTimeout>
:表示timer
可以是setTimeout
返回的定时器 ID。| null
:表示在初始状态下,timer
没有定时器,它的值为null
。
第四步:返回一个新函数
在防抖函数 debounce
中,我们希望当它被调用时,返回一个新的函数。这是防抖函数的核心机制,因为每次调用返回的新函数,实际上是在控制目标函数 func
的执行。
具体的想法是这样的:我们并不直接执行传入的目标函数 func
,而是返回一个新函数,这个新函数在被调用时会受到防抖的控制。
因此,我们要修改 debounce
函数,使它返回一个新的函数,真正控制 func
的执行时机。
function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
return function () {
// 防抖逻辑将在这里编写
};
}
- 返回新函数:当
debounce
被调用时,它返回一个新函数。这个新函数是每次调用时执行防抖逻辑的入口。 - 为什么返回新函数? :因为我们需要在每次事件触发时(例如用户输入时)执行防抖操作,而不是直接执行传入的目标函数
func
。
第五步:清除之前的定时器
为了实现防抖功能,每次调用返回的新函数时,我们需要先清除之前的定时器。如果之前有一个定时器在等待执行目标函数,我们应该将其取消,然后重新设置一个新的定时器。
这个步骤的关键就是使用 clearTimeout(timer)
。
function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
return function () {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}
// 下面将设置新的定时器
};
}
if (timer)
:我们检查timer
是否有值。如果它有值,说明之前的定时器还在等待执行,我们需要将其清除。clearTimeout(timer)
:这就是清除之前的定时器,防止之前的调用被执行。这个操作非常关键,因为它确保了只有最后一次调用(在延迟时间后)才会真正触发目标函数。
第六步:设置新的定时器
现在我们需要在每次调用返回的新函数时,重新设置一个新的定时器,让它在指定的延迟时间 duration
之后执行目标函数 func
。
这时候就要使用 setTimeout
来设置定时器,并在延迟时间后执行目标函数。
function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
return function () {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}
timer = setTimeout(() => {
func(); // 延迟后调用目标函数
}, duration);
};
}
setTimeout
:我们使用setTimeout
来设置一个新的定时器,定时器将在duration
毫秒后执行传入的目标函数func
。func()
:这是目标函数的实际执行点。定时器到达延迟时间时,它会执行目标函数func
。timer = setTimeout(...)
:我们将定时器的 ID 存储在timer
变量中,以便后续可以使用clearTimeout(timer)
来清除定时器。
第七步:支持参数传递
接下来是让这个防抖函数能够接受参数,并将这些参数传递给目标函数 func
。
为了实现这个功能,我们需要用到 ...args
来捕获所有传入的参数,并在执行目标函数时将这些参数传递过去。
function debounce(func: Function, duration: number) {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
return function (...args: any[]) { // 接收传入的参数
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}
timer = setTimeout(() => {
func(...args); // 延迟后调用目标函数,并传递参数
}, duration);
};
}
...args: any[]
:这表示新函数可以接收任意数量的参数,并将这些参数存储在args
数组中。func(...args)
:当定时器到达延迟时间后,调用目标函数func
,并将args
中的所有参数传递给它。这确保了目标函数能接收到我们传入的所有参数。
到这里,我们一个基本的防抖函数的实现。这个防抖函数实现了以下基本功能:
- 函数执行的延迟控制:每次调用时,都重新设置定时器,确保函数不会立即执行,而是在延迟结束后才执行。
- 多参数支持:通过
...args
,防抖函数能够接收多个参数,并将它们传递给目标函数。 - 清除之前的定时器:在每次调用时,如果定时器已经存在,先清除之前的定时器,确保只有最后一次调用才会生效。
但是,这样就完了吗?
在当前的实现中,
debounce
函数的定义是debounce(func: Function, duration: number)
,其中func: Function
用来表示目标函数。这种定义虽然可以工作,但它存在明显的缺陷和不足之处,尤其是在 TypeScript 强调类型安全的情况下。
缺陷 1:缺乏参数类型检查
Function
是一种非常宽泛的类型,它允许目标函数接收任何类型、任意数量的参数。因此定义目标函数 func
为 Function
类型意味着 TypeScript 无法对目标函数的参数类型进行任何检查。
const debounced = debounce((a: number, b: number) => {
console.log(a + b);
}, 200);
debounced("hello", "world"); // 这里不会报错,参数类型不匹配,但仍会被调用
在这个例子中,我们定义了一个目标函数,期望它接受两个数字类型的参数,但在实际调用时却传入了两个字符串。
这种情况下 TypeScript 不会提示任何错误,因为 Function
类型没有对参数类型进行限制。这种类型检查的缺失可能导致运行时错误或者逻辑上的错误。
缺陷 2:返回值类型不安全
同样,定义 func
为 Function
类型时,TypeScript 无法推断目标函数的返回值类型。这意味着防抖函数不能保证目标函数的返回值是符合预期的类型,可能导致返回值在其他地方被错误使用。
const debounced = debounce(() => {
return "result";
}, 200);
const result = debounced(); // TypeScript 不知道返回值类型,认为是 undefined
在这个例子中,虽然目标函数明确返回了一个字符串 "result"
,但 debounced
函数的返回值类型未被推断出来,因此 TypeScript 会认为它的返回值是 void
或 undefined
,即使目标函数实际上返回了 string
。
缺陷 3:缺乏目标函数的签名限制
由于 Function
类型允许任何形式的函数,因此 TypeScript 也无法检查目标函数的参数个数和类型是否匹配。这种情况下,如果防抖函数返回的新函数接收了错误数量或类型的参数,可能导致函数行为异常或意外的运行时错误。
const debounced = debounce((a: number) => {
console.log(a);
}, 200);
debounced(1, 2, 3); // TypeScript 不会报错,但多余的参数不会被使用
虽然目标函数只期望接收一个参数,但在调用时传入了多个参数。TypeScript 不会进行任何警告或报错,因为 Function
类型允许这种宽泛的调用,这可能会导致开发者误以为这些参数被使用。
总结 func: Function
的缺陷
- 缺乏参数类型检查:任何数量、任意类型的参数都可以传递给目标函数,导致潜在的参数类型错误。
- 返回值类型不安全:目标函数的返回值类型无法被推断,导致 TypeScript 无法确保返回值的类型正确。
- 函数签名不受限制:没有对目标函数的参数个数和类型进行检查,容易导致逻辑错误或参数使用不当。
这些缺陷使得代码在类型安全性和健壮性上存在不足,可能导致运行时错误或者隐藏的逻辑漏洞。
下一步的改进
为了解决这些缺陷,我们可以通过泛型的方式为目标函数添加类型限制,确保目标函数的参数和返回值类型都能被准确地推断和检查。这会是我们接下来要进行的优化。
第八步:使用泛型优化
为了克服 func: Function
带来的缺陷,我们可以通过 泛型 来优化防抖函数的类型定义,确保目标函数的参数和返回值都能在编译时进行类型检查。使用泛型不仅可以解决参数类型和返回值类型的检查问题,还可以提升代码的灵活性和安全性。
如何使用泛型进行优化?
我们将通过引入两个泛型参数来改进防抖函数的类型定义:
A
:表示目标函数的参数类型,可以是任意类型和数量的参数,确保防抖函数在接收参数时能进行类型检查。R
:表示目标函数的返回值类型,确保防抖函数返回的值与目标函数一致。
function debounce<A extends any[], R>(
func: (...args: A) => R, // 使用泛型 A 表示参数,R 表示返回值类型
duration: number // 延迟时间,以毫秒为单位
): (...args: A) => R { // 返回新函数,参数类型与目标函数相同,返回值类型为 R
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R; // 存储目标函数的返回值
return function (...args: A): R { // 返回的新函数,参数类型由 A 推断
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}
timer = setTimeout(() => {
lastResult = func(...args); // 延迟后调用目标函数,并存储返回值
}, duration);
return lastResult; // 返回上一次执行的结果,如果尚未执行则返回 undefined
};
}
A extends any[]
:A
表示目标函数的参数类型,A
是一个数组类型,能够适应目标函数接收多个参数的场景。通过泛型,防抖函数能够根据目标函数的签名推断出参数类型并进行检查。R
:R
表示目标函数的返回值类型,防抖函数能够确保返回值类型与目标函数一致。如果目标函数返回值类型为string
,防抖函数也会返回string
,这样可以防止返回值类型不匹配。lastResult
:用来存储目标函数的最后一次返回值。每次调用目标函数时会更新lastResult
,并在调用时返回上一次执行的结果,确保防抖函数返回正确的返回值。
泛型优化后的优点:
- 类型安全的参数传递:
通过泛型A
,防抖函数可以根据目标函数的签名进行类型检查,确保传入的参数与目标函数一致,避免参数类型错误。
const debounced1 = debounce((a: number, b: string) => {
console.log(a, b);
}, 300);
debounced1(42, "hello"); // 正确,参数类型匹配
debounced1("42", 42); // 错误,类型不匹配
- 返回值类型安全:
泛型R
确保了防抖函数的返回值与目标函数的返回值类型一致,防止不匹配的类型被返回。
const debounced = debounce(() => {
return "result";
}, 200);
const result = debounced(); // 返回值为 string
console.log(result); // 输出 "result"
- 支持多参数传递:
泛型A
表示参数类型数组,这意味着目标函数可以接收多个参数,防抖函数会将这些参数正确传递给目标函数。而如果防抖函数返回的新函数接收了错误数量或类型的参数,会直接报错提示。
const debounced = debounce((name: string, age: number) => {
return `${name} is ${age} years old.`;
}, 300);
const result = debounced("Alice", 30);
console.log(result); // 输出 "Alice is 30 years old."
第九步:添加 cancel
方法并处理返回值类型
在前面的步骤中,我们已经实现了一个可以延迟执行的防抖函数,并且支持参数传递和返回目标函数的结果。
但是,由于防抖函数的执行是异步延迟的,因此在初次调用时,防抖函数可能无法立即返回结果。因此函数的返回值我们需要使用 undefined
来表示目标函数的返回结果可能出现还没生成的情况。
除此之外,我们还要为防抖函数添加一个 cancel
方法,用于手动取消防抖的延迟执行。
为什么需要 cancel
方法?
在一些场景下,可能需要手动取消防抖操作,例如:
- 用户取消了操作,不希望目标函数再执行。
- 某个事件或操作已经不再需要处理,因此需要取消延迟中的函数调用。
为了解决这些需求,cancel
方法可以帮助我们在定时器还未触发时,清除定时器并停止目标函数的执行。
// 定义带有 cancel 方法的防抖函数类型
type DebouncedFunction<A extends any[], R> = {
(...args: A): R | undefined; // 防抖函数本身,返回值可能为 R 或 undefined
cancel: () => void; // `cancel` 方法,用于手动清除防抖
};
// 实现防抖函数
function debounce<A extends any[], R>(
func: (...args: A) => R, // 泛型 A 表示参数类型,R 表示返回值类型
duration: number // 延迟时间
): DebouncedFunction<A, R> { // 返回带有 cancel 方法的防抖函数
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R | undefined; // 用于存储目标函数的返回值
// 防抖逻辑的核心函数
const debouncedFn = function (...args: A): R | undefined {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}
// 设置新的定时器
timer = setTimeout(() => {
lastResult = func(...args); // 延迟后执行目标函数,并存储返回值
}, duration);
// 返回上一次的结果或 undefined
return lastResult;
};
// 添加 `cancel` 方法,用于手动取消防抖
debouncedFn.cancel = function () {
if (timer) {
clearTimeout(timer); // 清除定时器
timer = null; // 重置定时器
}
};
return debouncedFn; // 返回带有 `cancel` 方法的防抖函数
}
- 返回值类型
R | undefined
:
R
:代表目标函数的返回值类型,例如number
或string
。undefined
:在防抖函数的首次调用或目标函数尚未执行时,返回undefined
,表示结果尚未生成。lastResult
用于存储目标函数上一次执行的结果,防抖函数在每次调用时会返回该结果,或者在尚未执行时返回undefined
。
cancel
方法:
cancel
方法的作用是清除当前的定时器,防止目标函数在延迟时间结束后被执行。- 通过调用
clearTimeout(timer)
,我们可以停止挂起的防抖操作,并将timer
重置为null
,表示当前没有挂起的定时器。
让我们来看一个具体的使用示例,展示如何使用防抖函数,并在需要时手动取消操作。
// 定义一个简单的目标函数
const debouncedLog = debounce((message: string) => {
console.log(message);
return message;
}, 300);
// 第一次调用防抖函数,目标函数将在 300 毫秒后执行
debouncedLog("Hello"); // 如果不取消,300ms 后会输出 "Hello"
// 手动取消防抖,目标函数不会执行
debouncedLog.cancel();
在这个示例中:
- 调用
debouncedLog("Hello")
:会启动一个 300 毫秒的延迟执行,目标函数计划在 300 毫秒后执行,并输出"Hello"
。 - 调用
debouncedLog.cancel()
:会清除定时器,目标函数不会执行,避免了不必要的操作。
第十步:将防抖函数作为工具函数单独放在一个 ts
文件中并添加 JSDoc 注释
在编写好防抖函数之后,下一步是将其作为一个工具函数放入单独的 .ts
文件中,以便在项目中重复使用。同时,我们可以为函数添加详细的 JSDoc 注释,方便使用者了解函数的作用、参数、返回值及用法。
1. 将防抖函数放入单独的文件
首先,我们可以创建一个名为 debounce.ts
的文件,并将防抖函数的代码放在其中。
// debounce.ts
export type DebouncedFunction<A extends any[], R> = {
(...args: A): R | undefined; // 防抖函数本身,返回值可能为 R 或 undefined
cancel: () => void; // `cancel` 方法,用于手动清除防抖
};
/**
* 创建一个防抖函数,确保在最后一次调用后,目标函数只会在指定的延迟时间后执行。
* 防抖函数可以防止某个函数被频繁调用,例如用户输入事件、滚动事件或窗口调整大小等场景。
*
* @template A - 函数接受的参数类型。
* @template R - 函数的返回值类型。
* @param {(...args: A) => R} func - 需要防抖的目标函数。该函数将在延迟时间后执行。
* @param {number} duration - 延迟时间(以毫秒为单位)。在这个时间内,如果再次调用函数,将重新计时。
* @returns {DebouncedFunction<A, R>} 一个防抖后的函数,该函数包括一个 `cancel` 方法用于清除防抖。
*
* @example
* const debouncedLog = debounce((message: string) => {
* console.log(message);
* return message;
* }, 300);
*
* debouncedLog("Hello"); // 300ms 后输出 "Hello"
* debouncedLog.cancel(); // 取消防抖,函数不会执行
*/
export function debounce<A extends any[], R>(
func: (...args: A) => R,
duration: number
): DebouncedFunction<A, R> {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R | undefined; // 存储目标函数的返回值
const debouncedFn = function (...args: A): R | undefined {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}
timer = setTimeout(() => {
lastResult = func(...args); // 延迟后执行目标函数,并存储返回值
}, duration);
return lastResult; // 返回上次执行的结果,如果尚未执行则返回 undefined
};
debouncedFn.cancel = function () {
if (timer) {
clearTimeout(timer); // 清除定时器,防止目标函数被执行
timer = null; // 重置定时器
}
};
return debouncedFn;
}
2. 详细的 JSDoc 注释说明
通过添加 JSDoc 注释,能够为函数使用者提供清晰的文档信息,说明防抖函数的功能、参数类型、返回值类型,以及如何使用它。
JSDoc 注释的结构说明:
@template A, R
:说明泛型A
是函数接受的参数类型,R
是目标函数的返回值类型。@param
:解释函数的输入参数,说明func
是目标函数,duration
是防抖的延迟时间。@returns
:说明返回值是一个带有cancel
方法的防抖函数,函数返回值类型是R | undefined
。@example
:为函数提供示例,展示防抖函数的典型用法,包括取消防抖操作。
使用 JSDoc 生成文档
通过在 .ts
文件中添加 JSDoc 注释,可以借助 TypeScript 编辑器或 IDE(如 VSCode/Webstorm)自动生成代码提示和函数文档说明,提升开发体验。
例如,当开发者在使用 debounce
函数时,可以自动看到函数的说明和参数类型提示:
回顾:泛型防抖函数的最终效果
通过前面各个步骤的优化,我们已经构建了一个类型安全的防抖函数,结合泛型实现了以下关键功能:
- 类型安全的参数传递:
通过泛型A
,防抖函数能够根据目标函数的签名进行参数类型检查,确保传入的参数与目标函数的类型一致。如果传入的参数类型不匹配,TypeScript 将在编译时报错,避免运行时的潜在错误。
const debounced1 = debounce((a: number, b: string) => {
console.log(a, b);
}, 300);
debounced1(42, "hello"); // 正确,参数类型匹配
debounced1("42", 42); // 错误,类型不匹配
在上面的例子中,TypeScript 会检查参数类型,确保传入的参数符合预期的类型。错误的参数类型会被及时捕捉。
- 返回值类型安全:
泛型R
确保防抖函数的返回值与目标函数的返回值类型保持一致。TypeScript 可以根据目标函数的返回值类型推断防抖函数的返回值,防止不匹配的类型被返回。
const debounced = debounce(() => {
return "result";
}, 200);
const result = debounced(); // 返回值为 string
console.log(result); // 输出 "result"
在这个例子中,debounce
返回的防抖函数的返回值类型为string
或者undefind
,因为在防抖函数的实现中,目标函数是延迟执行的,因此在初次调用或在延迟期间,debounced
函数返回的结果可能尚未生成,与目标函数的返回值类型预期一致。 - 支持多参数传递:
泛型A
表示目标函数的参数类型数组,这意味着防抖函数可以正确传递多个参数,并确保类型安全。如果传入了错误数量或类型的参数,TypeScript 会提示开发者进行修正。
const debounced = debounce((name: string, age: number) => {
return `${name} is ${age} years old.`;
}, 300);
const result = debounced("Alice", 30);
console.log(result); // 输出 "Alice is 30 years old."
在这个例子中,防抖函数正确地将多个参数传递给目标函数,并输出目标函数的正确返回值。传入的参数数量或类型不正确时,TypeScript 会发出报错提示。
总结
至此,我们完整实现并优化了一个类型安全的防抖函数,并通过泛型确保参数和返回值的类型安全。此外,我们还详细讲解了如何为防抖函数添加 cancel
方法,并处理延迟执行的返回值 R | undefined
。最后,我们将防抖函数封装在一个单独的 TypeScript 文件中,并为其添加了 JSDoc 注释,使其成为一个可复用的工具函数。
通过这种方式,防抖函数不仅功能强大,还能在编译时提供类型检查,减少运行时的潜在错误。TypeScript
的类型系统帮助我们提升了代码的安全性和健壮性。
最后,我们给出完整的的代码如下:
// debounce.ts
export type DebouncedFunction<A extends any[], R> = {
(...args: A): R | undefined; // 防抖函数本身,返回值可能为 R 或 undefined
cancel: () => void; // `cancel` 方法,用于手动清除防抖
};
/**
* 创建一个防抖函数,确保在最后一次调用后,目标函数只会在指定的延迟时间后执行。
* 防抖函数可以防止某个函数被频繁调用,例如用户输入事件、滚动事件或窗口调整大小等场景。
*
* @template A - 函数接受的参数类型。
* @template R - 函数的返回值类型。
* @param {(...args: A) => R} func - 需要防抖的目标函数。该函数将在延迟时间后执行。
* @param {number} duration - 延迟时间(以毫秒为单位)。在这个时间内,如果再次调用函数,将重新计时。
* @returns {DebouncedFunction<A, R>} 一个防抖后的函数,该函数包括一个 `cancel` 方法用于清除防抖。
*
* @example
* const debouncedLog = debounce((message: string) => {
* console.log(message);
* return message;
* }, 300);
*
* debouncedLog("Hello"); // 300ms 后输出 "Hello"
* debouncedLog.cancel(); // 取消防抖,函数不会执行
*/
export function debounce<A extends any[], R>(
func: (...args: A) => R,
duration: number
): DebouncedFunction<A, R> {
let timer: ReturnType<typeof setTimeout> | null = null; // 定时器变量
let lastResult: R | undefined; // 存储目标函数的返回值
const debouncedFn = function (...args: A): R | undefined {
if (timer) {
clearTimeout(timer); // 清除之前的定时器
}
timer = setTimeout(() => {
lastResult = func(...args); // 延迟后执行目标函数,并存储返回值
}, duration);
return lastResult; // 返回上次执行的结果,如果尚未执行则返回 undefined
};
debouncedFn.cancel = function () {
if (timer) {
clearTimeout(timer); // 清除定时器,防止目标函数被执行
timer = null; // 重置定时器
}
};
return debouncedFn;
}
来源:juejin.cn/post/7431889821168812073
为什么一个文件的代码不能超过300行?
先说观点:在进行前端开发时,单个文件的代码行数推荐最大不超过300行,而超过1000行的都可以认为是垃圾代码,需要进行重构。
为什么是300
当然,这不是一个完全精准的数字,你一个页面301行也并不是什么犯天条的大罪,只是一般情况下,300行以下的代码可读性会更好。
起初,这只是林叔根据自己多年的工作经验拍脑袋拍出来的一个数字,据我观察,常规的页面开发,或者说几乎所有的前端页面开发,在进行合理的组件化拆分后,页面基本上都能保持在300行以下,当然,一个文件20行也并没有什么不妥,这里只是说上限。
但是拍脑袋得出的结论是不能让人信服的,于是林叔突发奇想想做个实验,看看这些开源大佬的源码文件都是多少行,于是我开发了一个小脚本。给定一个第三方的源文件所在目录,读取该目录下所有文件的行数信息,然后统计该库下文件的最长行数、最短行数、平均行数、小于500行/300行/200行/100行的文件占比。
脚本实现如下,感兴趣的可以看一下,不感兴趣的可以跳过看统计结果。统计排除了css样式文件以及测试相关文件。
const fs = require('fs');
const path = require('path');
let fileList = []; //存放文件路径
let fileLengthMap = {}; //存放每个文件的行数信息
let result = { //存放统计数据
min: 0,
max: 0,
avg: 0,
lt500: 0,
lt300: 0,
lt200: 0,
lt100: 0
}
//收集所有路径
function collectFiles(sourcePath){
const isFile = function (filePath){
const stats = fs.statSync(filePath);
return stats.isFile()
}
const shouldIgnore = function (filePath){
return filePath.includes("__tests__")
|| filePath.includes("node_modules")
|| filePath.includes("output")
|| filePath.includes("scss")
|| filePath.includes("style")
}
const getFilesOfDir = function (filePath){
return fs.readdirSync(filePath)
.map(file => path.join(filePath, file));
}
//利用while实现树的遍历
let paths = [sourcePath]
while (paths.length){
let fileOrDirPath = paths.shift();
if(shouldIgnore(fileOrDirPath)){
continue;
}
if(isFile(fileOrDirPath)){
fileList.push(fileOrDirPath);
}else{
paths.push(...getFilesOfDir(fileOrDirPath));
}
}
}
//获取每个文件的行数
function readFilesLength(){
fileList.forEach((filePath) => {
const data = fs.readFileSync(filePath, 'utf8');
const lines = data.split('\n').length;
fileLengthMap[filePath] = lines;
})
}
function statisticalMin(){
let min = Infinity;
Object.keys(fileLengthMap).forEach((key) => {
if (min > fileLengthMap[key]) {
min = fileLengthMap[key];
}
})
result.min = min;
}
function statisticalMax() {
let max = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (max < fileLengthMap[key]) {
max = fileLengthMap[key];
}
})
result.max = max;
}
function statisticalAvg() {
let sum = 0;
Object.keys(fileLengthMap).forEach((key) => {
sum += fileLengthMap[key];
})
result.avg = Math.round(sum / Object.keys(fileLengthMap).length);
}
function statisticalLt500() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 500) {
count++;
}
})
result.lt500 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt300() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 300) {
count++;
}
})
result.lt300 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt200() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 200) {
count++;
}
})
result.lt200 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
function statisticalLt100() {
let count = 0;
Object.keys(fileLengthMap).forEach((key) => {
if (fileLengthMap[key] < 100) {
count++;
}
})
result.lt100 = (count / Object.keys(fileLengthMap).length * 100).toFixed(2) + '%';
}
//统计
function statistics(){
statisticalMin();
statisticalMax();
statisticalAvg();
statisticalLt500();
statisticalLt300();
statisticalLt200();
statisticalLt100();
}
//打印
function print(){
console.log(fileList)
console.log(fileLengthMap)
console.log('最长行数:', result.max);
console.log('最短行数:', result.min);
console.log('平均行数:', result.avg);
console.log('小于500行的文件占比:', result.lt500);
console.log('小于300行的文件占比:', result.lt300);
console.log('小于200行的文件占比:', result.lt200);
console.log('小于100行的文件占比:', result.lt100);
}
function main(path){
collectFiles(path);
readFilesLength();
statistics();
print();
}
main(path.resolve(__dirname,'./vue-main/src'))
利用该脚本我对Vue、React、ElementPlus和Ant Design这四个前端最常用的库进行了统计,结果如下:
库 | 小于100行占比 | 小于200行占比 | 小于300行占比 | 小于500行占比 | 平均行数 | 最大行数 | 备注 |
---|---|---|---|---|---|---|---|
vue | 60.8% | 84.5% | 92.6% | 98.0% | 112 | 1000 | 仅1个模板文件编译的为1000行 |
react | 78.0% | 92.0% | 94.0% | 98.0% | 96 | 1341 | 仅1个JSX文件编译的为1341行 |
element-plus | 73.6% | 90.9% | 95.8% | 98.8 | 75 | 950 | |
ant-design | 86.9% | 96.7% | 98.7% | 99.5% | 47 | 722 |
可以看出95%左右的文件行数都不超过300行,98%的都低于500行,而每个库中超过千行以上的文件最多也只有一个,而且还都是最复杂的模板文件编译相关的代码,我们平时写的业务代码复杂度远远小于这些优秀的库,那我们有什么理由写出那么冗长的代码呢?
从这个数据来看,林叔的判断是正确的,代码行数推荐300行以下,最好不超过500行,禁止超过1000行。
为什么不要超过300
现在,请你告诉我,你见过最难维护的代码文件是什么样的?它们有什么特点?
没错,那就是大,通常来说,难维护的代码会有3个显著特点:耦合严重、可读性差、代码过长,而代码过长是难以维护的最重要的原因,就算耦合严重、可读性差,只要代码行数不多,我们总还能试着去理解它,但一旦再伴随着代码过长,就超过我们大脑(就像计算机的CPU和内存)的处理上限了,直接死机了。
这是由于我们的生理结构决定的,大脑天然就喜欢简单的事物,讨厌复杂的事物,不信咱们做个小测试,试着读一遍然后记住下面的几个字母:
F H U T L P
怎么样,记住了吗?是不是非常简单,那我们再来看下下面的,还是读一遍然后记住:
J O Q S D R P M B C V X
这次记住了吗?这才12个字母而已,而上千行的代码中,包含各种各样的调用关系、数据结构等,为了搞懂一个功能可能还要跳转好几个函数,这么复杂的信息,是不是对大脑的要求有点过高了。
代码行数过大通常是难以维护的最大原因。
怎么不超过300
现在前端组件化编程这么流行,这么方便,我实在找不出还要写出超大文件的理由,我可以"武断"地说,凡是写出大文件的同学,都缺乏结构化思维和分治思维。
面向结构编程,而不是面向细节编程
以比较简单的官网开发为例,喜欢面向细节编程的同学,可能得实现是这样的:
<div>
<div class="header">
<img src="logo.png"/>
<h1>网站名称h1>
div>
<div class="main-content">
<div class="banner">
<ul>
<li><img src="banner1.png">li>
ul>
div>
<div class="about-us">
div>
div>
div>
其中省略了N行代码,通常他们写出的页面都非常的长,光Dom可能都有大几百行,再加上JS逻辑以及CSS样式,轻松超过1000行。
现在假如领导让修改"关于我们"的相关代码,我们来看看是怎么做的:首先从上往下阅读代码,在几千行代码中找到"关于我们"部分的DOM,然后再从几千行代码中找到相关的JS逻辑,这个过程中伴随着鼠标的反复上下滚动,眼睛像扫描仪一样一行行扫描,生怕错过了某行代码,这样的代码维护起来无疑是让人痛苦的。
面向结构开发的同学实现大概是这样的:
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
main>
<Footer/>
div>
我们首先看到的是页面的结构、骨架,如果领导还是让我们修改"关于我们"的代码,你会怎么做,是不是毫不犹豫地就进入AboutUs组件的实现,无关的信息根本不会干扰到你,而且AboutUs的逻辑都集中在组件内部,也符合高内聚的编程原则。
特别是关于表单的开发,面向细节编程的情况特别严重,也造成表单文件特别容易变成超大文件,比如下面这个图,在一个表单中有十几个表单项,其中有一个选择商品分类的下拉选择框。
面向细节编程的同学喜欢直接把每个表单项的具体实现,杂糅在表单组件中,大概如下这样:
这还只是一个非常简单的表单项,你看看,就增加了这么多细节,如果是比较复杂点的表单项,其代码就更多了,这么多实现细节混合在这里,你能轻易地搞明白每个表单项的实现吗?你能说清楚这个表单组件的主线任务吗?
面向结构编程的同学会把它抽取为表单项组件,这样表单组件中只需要关心表单初始化、校验规则配置、保存逻辑等应该表单组件处理的内容,而不再呈现各种细节,实现了关注点的分离。
分而治之,大事化小
在进行复杂功能开发时,应该首先通过结构化思考,将大功能拆分为N个小功能,具体每个小功能怎么实现,先不用关心,在结构搭建完成后,再逐个问题击破。
仍然以前面提到的官网为例,首先把架子搭出来,每个子组件先不要实现,只要用一个简单的占位符占个位就行。
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
main>
<Footer/>
div>
每个子组件刚开始先用个Div占位,具体实现先不管。
架子搭好后,再去细化每个子组件的实现,如果子组件很复杂,利用同样的方式将其拆分,然后逐个实现。相比上来就实现一个超大的功能,这样的实现更加简单可执行,也方便我们看到自己的任务进度。
可以看到,我们实现组件拆分的目的,并不是为了组件的复用(复用也是组件化拆分的一个主要目的),而是为了更好地呈现功能的结构,实现关注点的分离,增强可读性和可维护性,同时通过这种拆分,将复杂的大任务变成可执行的小任务,更容易完成且能看到进度。
总结
前端单个文件代码建议不超过300行,最大上限为500行,严禁超过100行。
应该面向结构编程,而不是面向细节编程,要能看到一个组件的主线任务,而不被其中的实现细节干扰,实现关注点分离。
将大任务拆分为可执行的小任务,先进行占位,后逐个实现。
来源:juejin.cn/post/7431575865152618511
太强了!这个js库有200多个日期时间函数
笔者在多年的职业生涯中,用过很多 js 日期时间操作库,如今唯爱这一个,它就是 date-fns
。
这是一个拥有 200多个
日期时间函数的集合,堪称日期时间中的 Lodash
。
支持按需导出,最大可能地降低打包体积,也支持函数式,链式调用风格。
当前维护非常积极,star 数 35k
,提交了 2000多次
,近 400个
代码贡献者,被超过 400万
个项目所依赖使用。
2024年9月份发布了 v4
大版本,支持不同时区的时间操作和互转等,特点如下图。
话不多说,看几个示例:
格式化
import { format, formatDistance, formatRelative, subDays } from 'date-fns';
format(new Date(), "'Today is a' eeee");
//=> "Today is a Saturday"
formatDistance(subDays(new Date(), 3), new Date(), { addSuffix: true });
//=> "3 days ago"
formatRelative(subDays(new Date(), 3), new Date());
//=> "last Friday at 7:26 p.m."
国际化
import { formatRelative, subDays } from 'date-fns';
import { es, ru } from 'date-fns/locale';
formatRelative(subDays(new Date(), 3), new Date());
//=> "last Friday at 7:26 p.m."
formatRelative(subDays(new Date(), 3), new Date(), { locale: es });
//=> "el viernes pasado a las 19:26"
formatRelative(subDays(new Date(), 3), new Date(), { locale: ru });
//=> "в прошлую пятницу в 19:26"
组合与函数式
import { addYears, formatWithOptions } from 'date-fns/fp';
import { eo } from 'date-fns/locale';
const addFiveYears = addYears(5);
const dateToString = formatWithOptions({ locale: eo }, 'D MMMM YYYY');
const dates = [
new Date(2017, 0, 1),
new Date(2017, 1, 11),
new Date(2017, 6, 2)
];
const toUpper = (arg) => String(arg).toUpperCase();
const formattedDates = dates.map(addFiveYears).map(dateToString).map(toUpper);
//=> ['1 JANUARO 2022', '11 FEBRUARO 2022', '2 JULIO 2022']
可以看到,这操作,非常地 Lodash
!
不过呢,官方文档,笔者每次查看都感觉有点不方便。
所以,特意根据官方文档制作了一份中文文档,点击查看 date-fns 中文文档。
不过,笔者目前还没翻译完毕,还在持续进行中,感兴趣的朋友可以先提前关注一下,也欢迎与我微信交流探讨。
那么关于 date-fns
的安利,基本就结束了,本身就是一个函数库而已,没有太多可以细说的地方。
由于笔者比较八卦,我们来看一看 date-fns
周边的数据和信息。
日期时间的操作,是一个非常基础且重要的领域。
果不其然地,这个项目的赞助者非常之多,不乏很多出名的产品和公司。
截至目前,共收到了近 23 万美元的赞助。
项目的发起者是 Sasha
,是一个独立开发者,自2017年起,就一直全职在做开源项目,目前和一家人生活在新加坡。
笔者发现,国外的独立开发者,不依托于公司而具备赚钱和生存能力的人有不少。
这确实是一个非常不错的生活方式,可以自由地选择自己觉得舒适的生活。
笔者目前也是独立开发者,2024年,是笔者做自由职业的地 4 第四年,做全职独立开发的第1年,目前超过 10 个产品有或多或少的收益。
如果对独立开发感兴趣,欢迎与我交流探讨。
来源:juejin.cn/post/7432588086418948131
小项目自动化部署用 Jenkins 太麻烦了怎么办
导读
本文介绍用 Webhooks 代替 Jenkins 更简单地实现自动化部署。不论用 Jenkins 还是 Webhooks,都需要一定的服务端基础。
Webhooks 的使用更简单,自然功能就不如 Jenkins 丰富,因此更适合小项目。
背景
笔者一直在小厂子做小项目,只做前端的时候,部署项目就是 npm run build
然后压缩发给后端。后来到另一个小厂子做全栈,开始自己部署,想着捣鼓一下自动化部署。
Jenkins 是最流行的自动化部署工具,但是弄到一半我头都大了。我只是想部署一个小项目而已,结果安装、配置、启动 Jenkins 这工作量好像比我手动部署还大呢,必须找个更简单的办法才行。果然经过一番捣鼓,发现 Webhooks 又简单又实用,更适合我们小厂子小项目。
原理
首先我们的项目应该都放在 Git 平台上,主流的 Git 平台上都有 Webhooks。它的作用是:在你推送代码、发布版本等操作时,自动向你提供的地址发一个请求。
你的服务器收到这个请求后,要做的事情就是调用一段事先写好的脚本。这段脚本的任务是拉取最新代码、安装依赖(可选)、打包项目、重新启动项目。
这样当你在 Git 平台上发布版本后,服务器就会自动部署最新代码了。
实现
实现步骤可以和上面的原理反着来:先写好脚本,然后启动服务,最后创建 Webhooks。
在此之前,你的服务器需要先安装 Git,并能够拉取你的代码。这部分内容很常规,看官可以在其他地方搜到。
1. 自动部署脚本
Nuxt
自动部署脚本就是代替我们手动打包、部署的工作。在 Linux 中,它应该写在一个 .sh 文件里。我的前端项目用 Nuxt 开发,脚本主要内容如下:
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull
# 打包
npm run build
# 重启服务
pm2 reload ecosystem.config.js
你可以在 Git 上随便更新点内容,然后在 XShell 或其他工具打开服务器控制台,执行这段代码,然后到线上版本看更新有没有生效。
笔记一开始经过了一番折腾,发现最好得记录部署日志,那样方便排查问题。完整脚本如下:
# 日志文件路径
LOG_FILE="/usr/local/example/$(date).txt"
# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull >> $LOG_FILE 2>&1
# 打包
npm run build >> $LOG_FILE 2>&1
# 重启服务
pm2 reload ecosystem.config.js >> $LOG_FILE 2>&1
# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE
Eggjs
笔者后端用了 Eggjs,其自动部署脚本如下:
# 日志文件
LOG_FILE="/usr/local/example/$(date).txt"
# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull >> $LOG_FILE 2>&1
# Egg 没有重启命令,要先 stop 再 start
npm stop >> $LOG_FILE 2>&1
npm start >> $LOG_FILE 2>&1
# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE
Eggjs 项目没有构建的步骤,其依赖要事先安装好。因此如果开发过程中安装了新依赖,记得到服务端安装一下。
Midwayjs
由于 Eggjs 对 TypeScript 的支持比较差,笔者后来还用了 Midwayjs 来开发服务端,其脚本如下:
# 日志文件
LOG_FILE="/usr/local/example/$(date).txt"
# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull >> $LOG_FILE 2>&1
# 重装依赖
export NODE_ENV=development
npm install >> $LOG_FILE 2>&1
# 构建
npm run build >> $LOG_FILE 2>&1
# 移除开发依赖
npm prune --omit=dev >> $LOG_FILE 2>&1
# 启动服务
npm start >> $LOG_FILE 2>&1
# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE
Midwayjs 的自动部署脚本比较特殊:在 npm install
之前需要先指定环境为 development
,那样才会安装所有依赖,否则会忽略 devDependencies
中的依赖,导致后面的 npm run build
无法执行。这点也是费了笔者好长时间才排查清楚,因为它在 XShell 里执行的时候默认的环境就是 development
,但是到了 Webhooks 调用的时候又变成了 product
。
2. 启动一个独立的小服务
上面这些脚本,应该由一个独立的小服务来执行。笔者一开始让项目的 Eggjs 服务来执行,也就是想让 Eggjs 服务自动部署自己,就失败了。原因是:脚本在执行到 npm stop
时,Eggjs 服务把自己关掉了,自然就执行不了 npm start
。
笔者启动了一个新的 Eggjs 服务来实现这个功能,使用其他语言、框架同理。其中执行脚本的控制器代码如下:
const { Controller } = require('egg');
const { exec } = require('child_process');
class EggController extends Controller {
async index() {
const { ctx } = this;
try {
// 执行 .sh 脚本
await exec('sh /usr/local/example/egg.sh');
ctx.body = {
'msg': 'Deployment successful'
};
} catch (error) {
ctx.body = {
'msg': 'Deployment failed:' + JSON.stringify(error)
};
}
}
}
module.exports = EggController;
如果启动成功,你应该可以在 Postman 之类的工具上发起这个控制器对应的请求,然后成功执行里面的 .sh 脚本。
注意这些请求必须是 POST 请求。
3. 到 Git 平台创建 Webhooks
笔者用的是GitCode,其他平台类似。到代码仓库 -> 项目设置 -> WebHook 菜单 -> 新建 Webhook:
- URL:上面独立小服务的请求地址;
- Token:在 Git 平台生成即可;
- 事件类型:我希望是发布版本的时候触发,所以选
Tag推送事件
。
创建好之后,激活这个 hook,然后随便提交些新东西,到代码仓库 -> 代码 -> 创建发行版:
填写版本号、版本描述后,滑到底部,勾选“最新版本”,点击发布按钮。
这样就能触发前面创建的 WebHook,向你的独立小服务发送请求,小服务就会去调用自动部署脚本。
怎么样,是不是比 Jenkins 简单太多了。当然功能也比 Jenkins 简单太多,但是对小厂子小项目来说,也是刚好够用。
来源:juejin.cn/post/7406238334215520291
增强 vw/rem 移动端适配,适配宽屏、桌面端、三折屏
vw 和 rem 是两个神奇的 CSS 长度单位,认识它们之前,我一度认为招聘广告上的“像素级还原”是一种超能力,我想具备这种能力的人,一定专业过硬、有一双高分辨率的深邃大眼睛。
时间一晃,入坑两年,我敏捷地移动有点僵硬不算过硬的小手,将一些固定的 px 尺寸复制到代码,等待编译阶段的 vw/rem 转换,刷新浏览器的功夫,完美还原的界面映入眼前,我推了推眼镜,会心一笑。多亏了 vw 和 rem。
TLDR:极简配置 postcss-mobile-forever 增强 vw 的宽屏可访问性,限制视图最大宽度。
用 vw 和 rem 适配移动端视图的结果是一致的,都会得到一个随屏幕宽度变化的等比例伸缩视图。一般使用 postcss-px-to-viewport 做 vw 适配,使用 postcss-px2rem 配合 amfe-flexible 做 rem 适配。由于 rem 适配的原理是模仿 vw,所以后面关于适配的增强,一律使用 vw 适配做对比。
vw 适配有一些优点(同样 rem):
- 加速开发效率;
- 像素级还原设计稿;
- 也许更容易被自动生成。
但是 vw 适配也不完美,它引出了下面的问题:
- 开发过程机械化,所有元素是固定的宽高,不考虑响应式设计,例如不使用
flex
、grid
布局; - 助长不规范化,不关注细节,只关注页面还原度和开发速度,例如在非按钮元素的
<div>
上添加点击事件; - 桌面端难以访问,包容性降低。
前两个问题,也许要抛弃 vw、回归响应式布局才能解决,在日常开发时,我们要约束自己以开发桌面端的标准来开发移动端页面,改善这两个问题。
马克·吐温在掌握通过密西西比河的方法之后,发现这条河已经失去了它的美丽——总会丢掉一些东西,但是在艺术中比较不受重视的东西同时也被创造出来了。让我们不要再注意丢掉了什么,而是注意获得了什么。 ——《禅与摩托车的维修艺术》
后面,我们将关注第三点,介绍如何在保持现状(vw 适配)的情况下,尽可能提高不同屏幕的包容性,至少让我们在三折屏的时代能得到从前 1 倍的体验,而不是 1/3。
移动端 | 桌面端 |
---|---|
![]() | ![]() |
上面是一个页面分别在手机和电脑上展示的截图,可以看到左图移动端的右上角没有隐藏分享按钮,所以用户是允许(也应该允许)被分享到桌面端访问的,可惜,当用户准备享受大屏震撼的时候,真的被震撼了:他不知道这个页面的技术细节是神奇的 vw,也不知道他只能用鼠标小心地拖动浏览器窗口边缘,直到窗口窄得和手机一样,最崩溃的是,当他得意地按下了浏览器的缩小按钮,页面像冰冷的机器纹丝不动,浇灭了他的最后一点自信。
限制最大宽度
由于 vw 是视口单位,因此当屏幕变宽,vw 元素也会变大,无限变宽,无限变大。
现在假设在一张宽度 600 像素的设计图上,有一个宽度 60px
的元素,最终通过工具,它会被转为 10vw
。这个 10vw
元素是任意伸缩的,但是现在我希望,当屏幕宽度扩大到 700px
后,停止元素的放大。
出现了一堆枯燥的数字,不用担心,后面还有一波,请保持耐心。
首先计算 10vw
在宽 700 像素的屏幕上,应该是多少像素:60 * 700 / 600 = 70。通过最大宽度(700px
)和标准宽度(600px
)的比例,乘以元素在标准宽度时的尺寸(60px
),得到了元素的最大尺寸 70px
。
接着结合 CSS 函数:min(10vw, 70px)
,这样元素的宽度将被限制在 70px
以内,小于这个宽度时会以 10vw
等比伸缩。
除了上面的作为正数的尺寸,可能还会有用于方位的负数,负数的情况则使用 CSS 函数 max()
,下面的代码块是一个具体实现:
/**
* 限制大小的 vw 转换
* @param {number} n
* @param {number} idealWidth 标准/设计稿/理想宽度,设计稿的宽度
* @param {number} maxWidth 表示伸缩视图的最大宽度
*/
function maxVw(n, idealWidth = 600, maxWidth = 700) {
if (n === 0) return n;
const vwN = Math.round(n * 100 / idealWidth);
const maxN = Math.round(n * maxWidth / idealWidth);
const cssF = n > 0 ? "min" : "max";
return `${cssF}(${vwN}vw, ${maxN}px)`;
}
矫正视图外的元素位置
上一节提供的方法,包容了限制最大宽度尺寸的大部分情况,但是如果不忘像素级还原的❤️初心,就会找到一些漏洞。
下面是一个图例,移动端页面提供了 Top 按钮用于帮助用户返回顶部,按照上一节的方法,Top 按钮会出现在中央移动端视图之外、右边的空白区域中,而不是矫正回中央移动端视图的右下角。
假设 Top 按钮的样式是这样的:
.top {
position: fixed;
right: 30px;
bottom: 30px;
/* ... */
}
按照标准宽度 600、最大宽度 700,上面的 30px
都被转换成了 min(5vw, 35px)
,bottom
没错,但 right
需要矫正。
对照上面右图矫正过的状态,right
的值 = 右半边的空白长度 + Top 按钮到居中视图右边框的长度 = 桌面端视图的一半 - 视图中线到 Top 按钮的右边框长度。
沿着第二个等号后面的思路,fixed
定位时桌面端视图一半的尺寸即为 50%
,中线到 Top 按钮右边框的长度,分两种情况:
- 在屏幕宽度大于最大宽度 700 时,为 700 / 2 - 30 * 700 / 600,即为
315px
(其中 700 / 2 是中线到移动端右边框长度,30 * 700 / 600 是屏宽 600 时的30px
在屏宽 700 时的尺寸); - 在屏幕宽度小于最大宽度 700 时,为 (600 / 2 - 30) / 600,即为
45%
。
结合 calc()
、min()
和上面得到的 50%
、315px
、45%
,参考第二个等式,可以得到 right
的新值为 calc(50% - min(315px, 45%))
。当尺寸大于移动端视图的一半时,会出现负数的情况,这时使用 max()
替换 min()
。
上面的计算方法是一种符合预期的稳定的方法,另一种方法是强制设置移动端视图的根元素成为包含块,设置之后,
right: min(5vw, 35px)
将不再基于浏览器边框,而是基于移动端视图的边框。
postcss-mobile-forever
上面介绍了增强 vw 以包容移动端视图在宽屏展示的两个方面,除了介绍的这些,还有一点点边角情况,例如:
- 逻辑属性的判断和转换;
- 矫正
fixed
定位时和包含块宽度有关的vw
和%
尺寸; - 矫正
fixed
定位时left
与right
的vw
和%
尺寸; - 为移动端视图添加居中样式;
- 各种情况的判断和转换方法选择。
postcss-mobile-forever 是一个 PostCSS 插件,利用 mobile-forever 这些工作可以在编译阶段完成,上面举了那么多例子,汇总成一份 mobile-forever 配置就是:
{
"viewportWidth": 700,
"appSelector": "#app",
"maxDisplayWidth": 600
}
上面是 mobile-forever 用户使用最多的模式,max-vw-mode,此外还提供:
- mq-mode,media-query 媒体查询模式,生成可访问性更高的样式,同样限制最大宽度,但是避免了
vw
带来的无法通过桌面端浏览器缩放按钮缩放页面的问题,也提供了更高的浏览器兼容性; - vw-mode,朴素地将固定尺寸转为 vw 伸缩页面,不限制最大宽度。
postcss-mobile-forever 相比 postcss-px-to-viewport 提供了更多的模式,包容了宽屏展示,相比 postcss-px2rem,无需加载 JavaScript,不为项目引入复杂度,即使用户禁用了 js,也能正常展示页面。
scale-view 提供运行时的转换方法。
优秀的模版
postcss-mobile-forever 的推广离不开开源模版的支持、尝试与反馈,下面是这些优秀的模版,它们为开发者提供了更多元的选项,为用户提供了更包容的产品:
- vue3-vant-mobile,一个基于 Vue 3 生态系统的移动 web 应用模板,帮助你快速完成业务开发。【查看在线演示】
- vue3-vant4-mobile,基于Vue3.4、Vite5、Vant4、Pinia、Typescript、UnoCSS等主流技术开发,集成 Dark Mode(暗黑)模式和系统主题色,且持久化保存,集成 Mock 数据,包括登录/注册/找回/keep-alive/Axios/useEcharts/IconSvg 等其他扩展。你可以在此之上直接开发你的业务代码!【查看在线演示】
- fantastic-mobile,一款自成一派的移动端 H5 框架,支持多款 UI 组件库,基于 Vue3。【查看在线演示】
增强后的 vw/rem 看起来已经完成了适配宽屏的任务,不过回想最初的另外两个问题,机械化的开发过程与不规范化的开发细节,没有解决。作为一名专业的前端开发工程师,请考虑使用响应式设计开发你的下一个项目,为三折屏带来 3 倍的用户体验吧。
来源:juejin.cn/post/7431558902171484211
前端进阶必须会的Zod !
大家好,我是白露。
今天我想和大家分享一个我最近在使用的TypeScript库 —— Zod。简单来说,Zod是一个用于数据验证的库,它可以让你的TypeScript代码更加安全和可靠。
最近几个月我一直在使用Zod,发现它不仅解决了我长期以来的一些痛点,还大大提高了我的开发效率。我相信,这个库也能帮助到许多和我有同样困扰的TypeScript开发者们。
1. 为什么需要Zod?
作为一个热爱TypeScript的程序员,我一直在寻找能够增强类型安全性的方法。
最近几年,我主要使用TypeScript进行开发。原因很简单:TypeScript提供了优秀的静态类型检查,特别是对于大型项目来说,它的类型系统可以帮助我们避免许多潜在的运行时错误。
然而,尽管TypeScript的类型系统非常强大,但它仍然存在一些局限性。特别是在处理运行时数据时,TypeScript的静态类型检查无法完全保证数据的正确性。这就是我开始寻找额外的数据验证解决方案的原因。
在这个过程中,我尝试了多种数据验证库,如Joi、Yup等。但它们要么缺乏与TypeScript的良好集成,要么使用起来过于复杂。直到我发现了Zod,它完美地解决了我的需求。
2. Zod是什么?
Zod是一个TypeScript优先的模式声明和验证库。它允许你创建复杂的类型安全验证模式,并在运行时执行这些验证。Zod的设计理念是"以TypeScript类型为先",这意味着你定义的每个Zod模式不仅可以在运行时进行验证,还可以被TypeScript编译器用来推断类型。
使用Zod的主要优势包括:
- 类型安全: Zod提供了从运行时验证到静态类型推断的端到端类型安全。
- 零依赖: Zod没有任何依赖项,这意味着它不会给你的项目增加额外的包袱。
- 灵活性: Zod支持复杂的嵌套对象和数组模式,可以处理几乎任何数据结构。
- 可扩展性: 你可以轻松地创建自定义验证器和转换器。
- 性能: Zod经过优化,可以处理大型和复杂的数据结构,而不会影响性能。
3. 如何使用Zod?
让我们通过一些实际的例子来看看如何使用Zod。
3.1 基本类型验证
import { z } from 'zod';
// 定义一个简单的字符串模式
const stringSchema = z.string();
// 验证
console.log(stringSchema.parse("hello")); // 输出: "hello"
console.log(stringSchema.parse(123)); // 抛出 ZodError
3.2 对象验证
const userSchema = z.object({
name: z.string(),
age: z.number().min(0).max(120),
email: z.string().email(),
});
type User = z.infer<typeof userSchema>; // 自动推断类型
const user = {
name: "Alice",
age: 30,
email: "alice@example.com",
};
console.log(userSchema.parse(user)); // 验证通过
3.3 数组验证
const numberArraySchema = z.array(z.number());
console.log(numberArraySchema.parse([1, 2, 3])); // 验证通过
console.log(numberArraySchema.parse([1, "2", 3])); // 抛出 ZodError
4. Zod的高级用法
Zod不仅可以处理基本的类型验证,还可以处理更复杂的场景。
4.1 条件验证
const personSchema = z.object({
name: z.string(),
age: z.number(),
drivingLicense: z.union([z.string(), z.null()]).nullable(),
}).refine(data => {
if (data.age < 18 && data.drivingLicense !== null) {
return false;
}
return true;
}, {
message: "未成年人不能持有驾-照",
});
4.2 递归模式
const categorySchema: z.ZodType<Category> = z.lazy(() => z.object({
name: z.string(),
subcategories: z.array(categorySchema).optional(),
}));
type Category = z.infer<typeof categorySchema>;
4.3 自定义验证器
const passwordSchema = z.string().refine(password => {
// 至少8个字符,包含大小写字母和数字
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;
return regex.test(password);
}, {
message: "密码必须至少8个字符,包含大小写字母和数字",
});
5. Zod与前端框架的集成
Zod可以很好地与各种前端框架集成。
这里我们以React为例,看看如何在React应用中使用Zod进行表单验证。
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
});
type FormData = z.infer<typeof schema>;
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("username")} placeholder="Username" />
{errors.username && <span>{errors.username.message}</span>}
<input {...register("email")} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
6. Zod与数据库的结合
Zod不仅可以用于前端验证,还可以与后端数据库模式定义完美结合。以下是一个使用Prisma和Zod的例子:
import { z } from 'zod';
import { Prisma } from '@prisma/client';
const userSchema = z.object({
id: z.number().optional(),
name: z.string().min(3),
email: z.string().email(),
age: z.number().min(18),
createdAt: z.date().optional(),
updatedAt: z.date().optional(),
});
type User = z.infer<typeof userSchema>;
// 使用Zod模式来定义Prisma模型
const userModel: Prisma.UserCreateInput = userSchema.omit({ id: true, createdAt: true, updatedAt: true }).parse({
name: "John Doe",
email: "john@example.com",
age: 30,
});
// 现在可以安全地将这个对象传递给Prisma的create方法
// prisma.user.create({ data: userModel });
7. Zod的性能优化
虽然Zod非常强大,但在处理大型数据结构时,可能会遇到性能问题。以下是一些优化建议:
- 延迟验证: 对于大型对象,考虑使用
z.lazy()
来延迟验证。 - 部分验证: 使用
z.pick()
或z.omit()
来只验证需要的字段。 - 缓存模式: 如果你频繁使用相同的模式,考虑缓存它们。
- 异步验证: 对于复杂的验证逻辑,考虑使用异步验证器。
8. Zod vs 其他验证库
Zod并不是市场上唯一的验证库。让我们简单比较一下Zod与其他流行的验证库:
- Joi: Joi是一个功能强大的验证库,但它不是TypeScript优先的,这意味着你需要额外的工作来获得类型推断。
- Yup: Yup与Zod非常相似,但Zod的API设计更加直观,而且性能通常更好。
- Ajv: Ajv是一个高性能的JSON Schema验证器,但它的API相对复杂,学习曲线较陡。
- class-validator: 这是一个基于装饰器的验证库,非常适合与TypeORM等ORM一起使用,但它需要使用实验性的装饰器特性。
相比之下,Zod提供了一个平衡的解决方案:它是TypeScript优先的,性能优秀,API直观,并且不需要任何实验性特性。
总而言之,通过使用Zod,你可以:
- 减少运行时错误
- 提高代码的可读性和可维护性
- 自动生成TypeScript类型
- 简化前后端之间的数据验证逻辑
开始使用Zod吧,让你的TypeScript代码更安全、更强大!
写了这么多,大家不点赞或者star一下,说不过去了吧?
延伸阅读
来源:juejin.cn/post/7426923218952847412
threejs做特效:实现物体的发光效果-EffectComposer详解!
简介与效果概览
各位大佬给个赞,感谢呢!
threejs的开发中,实现物体发光效果是一个常见需求,比如实现楼体的等待照明
要想实现这样的效果,我们只需要了解一个效果合成器概念:EffectComposer。
效果合成器能够合成各种花里胡哨的效果,好比是一个做特效的AE,本教程,我们将使用它来实现一个简单的发光效果。
如图,这是我们将导入的一个模型
.
我们要给他赋予灵魂,实现下面的发光效果
顺带的,我们要实现物体的自动旋转、一个简单的性能监视器、一个发光参数调节的面板
技术方案
原生html框架搭建
借助threejs实现一个物体发光效果非常简单,首先我们使用html搭建一个简单的开发框架
参考官方起步文档:three.js中文网
<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js物体发光效果</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
<link type="text/css" rel="stylesheet" href="./main.css" />
<style>
#info>* {
max-width: 650px;
margin-left: auto;
margin-right: auto;
}
</style>
</head>
<body>
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.163.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.163.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
</script>
</body>
</html>
上述代码中,我们采用type="importmap"
的方式引入了threejs开发 的一些核心依赖,"three"是开发的最基本依赖;在Three.js中,"addons" 通常指的是一些附加功能或扩展模块,它们提供了额外的功能,可以用于增强或扩展Three.js的基本功能。
在type="module"
中,我们引入了threejs的一些基础依赖,OrbitControls
轨道控制器和GLTFLoader
模型加载器。
实现模型的加载
我们将下载好的模型放在文件根目录
http://www.yanhuangxueyuan.com/threejs/examples/models/gltf/PrimaryIonDrive.glb
基于threejs的基础知识,我们先实现模型的加载与渲染
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
init()
function init() {
const container = document.getElementById("container");
// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// 设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置色调映射 这个属性用于在普通计算机显示器或者移动设备屏幕等低动态范围介质上,模拟、逼近高动态范围(HDR)效果。
renderer.toneMapping = THREE.ReinhardToneMapping;
container.appendChild(renderer.domElement);
// 创建新的场景对象。
const scene = new THREE.Scene();
// 创建透视相机
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
1,
100
);
camera.position.set(-5, 2.5, -3.5);
scene.add(camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.maxPolarAngle = Math.PI * 0.5;
controls.minDistance = 3;
controls.maxDistance = 8;
// 添加了一个环境光
scene.add(new THREE.AmbientLight(0xcccccc));
// 创建了一个点光源
const pointLight = new THREE.PointLight(0xffffff, 100);
camera.add(pointLight);
// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
const clip = gltf.animations[0];
renderer.render(scene, camera);
});
}
</script>
现在,我们的页面中就有了下面的场景
接下来,我们实现模型的发光效果添加。
模型发光效果添加
实现模型的发光效果,实际是EffectComposer效果合成器实现的。
官方定义:用于在three.js中实现后期处理效果。该类管理了产生最终视觉效果的后期处理过程链。 后期处理过程根据它们添加/插入的顺序来执行,最后一个过程会被自动渲染到屏幕上。
简单来说,EffectComposer效果合成器只是一个工具,它可以将多种效果集成,进行渲染。我们来看一个伪代码:
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
// 创建效果合成器
composer = new EffectComposer(renderer);
composer.addPass(发光效果);
composer.addPass(光晕效果);
composer.addPass(玻璃磨砂效果
// 渲染
composer.render();
它的实现过程大致如上述代码。要实现发光效果,我们需要先熟悉三个Pass。
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
- RenderPass: 渲染通道是用于传递渲染结果的对象。RenderPass是EffectComposer中的一个通道,用于将场景渲染到纹理上。(固定代码,相当于混合效果的开始)
- UnrealBloomPass: 这是一个用于实现逼真的辉光效果的通道。它模拟了逼真的辉光,使得场景中的亮部分在渲染后产生耀眼的辉光效果。(不同效果有不同的pass)
- OutputPass: OutputPass是EffectComposer中的一个通道,用于将最终渲染结果输出到屏幕上。(固定代码,相当于混合效果的结束)
现在,我们完整的实现发光效果
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
let camera;
let composer, renderer;
const params = {
threshold: 0,
strength: 1,
radius: 0,
exposure: 1,
};
init();
function init() {
const container = document.getElementById("container");
// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// 设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置色调映射 这个属性用于在普通计算机显示器或者移动设备屏幕等低动态范围介质上,模拟、逼近高动态范围(HDR)效果。
renderer.toneMapping = THREE.ReinhardToneMapping;
container.appendChild(renderer.domElement);
// 创建新的场景对象。
const scene = new THREE.Scene();
// 创建透视相机
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
1,
100
);
camera.position.set(-5, 2.5, -3.5);
scene.add(camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.maxPolarAngle = Math.PI * 0.5;
controls.minDistance = 3;
controls.maxDistance = 8;
// 添加了一个环境光
scene.add(new THREE.AmbientLight(0xcccccc));
// 创建了一个点光源
const pointLight = new THREE.PointLight(0xffffff, 100);
camera.add(pointLight);
// 创建了一个RenderPass对象,用于将场景渲染到纹理上。
const renderScene = new RenderPass(scene, camera);
// 创建了一个UnrealBloomPass对象,用于实现辉光效果。≈
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.4,
0.85
);
// 设置发光参数,阈值、强度和半径。
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;
// 创建了一个OutputPass对象,用于将最终渲染结果输出到屏幕上。
const outputPass = new OutputPass();
// 创建了一个EffectComposer对象,并将RenderPass、UnrealBloomPass和OutputPass添加到渲染通道中。
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(outputPass);
// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
const clip = gltf.animations[0];
animate();
});
}
function animate() {
requestAnimationFrame(animate);
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>
现在,我们就实现发光的基本效果了!
实现物体的自动旋转动画
现在,我们实现一下物体自身的旋转动画
AnimationMixer是three中的动画合成器,使用AnimationMixer可以解析到模型中的动画数据
// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
//创建了THREE.AnimationMixer 对象,用于管理模型的动画。
mixer = new THREE.AnimationMixer(model);
//从加载的glTF模型文件中获取动画数据。
//这里假设模型文件包含动画数据,通过 gltf.animations[0] 获取第一个动画片段。
const clip = gltf.animations[0];
// 使用 mixer.clipAction(clip) 创建了一个动画操作(AnimationAction),并立即播放该动画
mixer.clipAction(clip.optimize()).play();
animate();
});
实现动画更新
let clock;
clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
//使用了 clock 对象的 getDelta() 方法来获取上一次调用后经过的时间,即时间间隔(delta)。
const delta = clock.getDelta();
//根据上一次更新以来经过的时间间隔来更新动画。
//这个方法会自动调整动画的播放速度,使得动画看起来更加平滑,不受帧率的影响
mixer.update(delta);
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
完整代码
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
let camera, stats;
let composer, renderer, mixer, clock;
const params = {
threshold: 0,
strength: 1,
radius: 0,
exposure: 1,
};
init();
function init() {
const container = document.getElementById("container");
clock = new THREE.Clock();
// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// .....
// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
mixer = new THREE.AnimationMixer(model);
const clip = gltf.animations[0];
mixer.clipAction(clip.optimize()).play();
animate();
});
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>
优化屏幕缩放逻辑
init{
// ....
window.addEventListener("resize", onWindowResize);
}
function onWindowResize() {
const width = window.innerWidth;
const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
composer.setSize(width, height);
}
添加参数调节面板
在Three.js中,GUI是一个用于创建用户界面(UI)控件的库。具体来说,GUI库允许你在Three.js应用程序中创建交互式的图形用户界面元素,例如滑块、复选框、按钮等,这些元素可以用于控制场景中的对象、相机、光源等参数。
我们借助这个工具实现如下发光效果调试面板
import { GUI } from "three/addons/libs/lil-gui.module.min.js";
init{
// ....
// 创建一个GUI实例
const gui = new GUI();
// 创建一个名为"bloom"的文件夹,用于容纳调整泛光效果的参数
const bloomFolder = gui.addFolder("bloom");
// 在"bloom"文件夹中添加一个滑块控件,用于调整泛光效果的阈值参数
bloomFolder
.add(params, "threshold", 0.0, 1.0)
.onChange(function (value) {
bloomPass.threshold = Number(value);
});
// 在"bloom"文件夹中添加另一个滑块控件,用于调整泛光效果的强度参数
bloomFolder
.add(params, "strength", 0.0, 3.0)
.onChange(function (value) {
bloomPass.strength = Number(value);
});
// 在根容器中添加一个滑块控件,用于调整泛光效果的半径参数
gui
.add(params, "radius", 0.0, 1.0)
.step(0.01)
.onChange(function (value) {
bloomPass.radius = Number(value);
});
// 创建一个名为"tone mapping"的文件夹,用于容纳调整色调映射效果的参数
const toneMappingFolder = gui.addFolder("tone mapping");
// 在"tone mapping"文件夹中添加一个滑块控件,用于调整曝光度参数
toneMappingFolder
.add(params, "exposure", 0.1, 2)
.onChange(function (value) {
renderer.toneMappingExposure = Math.pow(value, 4.0);
});
window.addEventListener("resize", onWindowResize);
}
添加性能监视器
import Stats from "three/addons/libs/stats.module.js";
init{
stats = new Stats();
container.appendChild(stats.dom);
// ...
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
stats.update();
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
在Three.js中,Stats是一个性能监视器,用于跟踪帧速率(FPS)、内存使用量和渲染时间等信息。
完整demo代码
html
<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js物体发光效果</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
<link type="text/css" rel="stylesheet" href="./main.css" />
<style>
#info>* {
max-width: 650px;
margin-left: auto;
margin-right: auto;
}
</style>
</head>
<body>
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.163.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.163.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import Stats from "three/addons/libs/stats.module.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
let camera, stats;
let composer, renderer, mixer, clock;
const params = {
threshold: 0,
strength: 1,
radius: 0,
exposure: 1,
};
init();
function init() {
const container = document.getElementById("container");
stats = new Stats();
container.appendChild(stats.dom);
clock = new THREE.Clock();
// WebGL渲染器
// antialias是否执行抗锯齿。默认为false.
renderer = new THREE.WebGLRenderer({ antialias: true });
// 设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置色调映射 这个属性用于在普通计算机显示器或者移动设备屏幕等低动态范围介质上,模拟、逼近高动态范围(HDR)效果。
renderer.toneMapping = THREE.ReinhardToneMapping;
container.appendChild(renderer.domElement);
// 创建新的场景对象。
const scene = new THREE.Scene();
// 创建透视相机
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
1,
100
);
camera.position.set(-5, 2.5, -3.5);
scene.add(camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.maxPolarAngle = Math.PI * 0.5;
controls.minDistance = 3;
controls.maxDistance = 8;
// 添加了一个环境光
scene.add(new THREE.AmbientLight(0xcccccc));
// 创建了一个点光源
const pointLight = new THREE.PointLight(0xffffff, 100);
camera.add(pointLight);
// 创建了一个RenderPass对象,用于将场景渲染到纹理上。
const renderScene = new RenderPass(scene, camera);
// 创建了一个UnrealBloomPass对象,用于实现辉光效果。≈
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.4,
0.85
);
// 设置发光参数,阈值、强度和半径。
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;
// 创建了一个OutputPass对象,用于将最终渲染结果输出到屏幕上。
const outputPass = new OutputPass();
// 创建了一个EffectComposer对象,并将RenderPass、UnrealBloomPass和OutputPass添加到渲染通道中。
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(outputPass);
// 模型加载
new GLTFLoader().load("./PrimaryIonDrive.glb", function (gltf) {
const model = gltf.scene;
scene.add(model);
mixer = new THREE.AnimationMixer(model);
const clip = gltf.animations[0];
mixer.clipAction(clip.optimize()).play();
animate();
});
const gui = new GUI();
const bloomFolder = gui.addFolder("bloom");
bloomFolder
.add(params, "threshold", 0.0, 1.0)
.onChange(function (value) {
bloomPass.threshold = Number(value);
});
bloomFolder
.add(params, "strength", 0.0, 3.0)
.onChange(function (value) {
bloomPass.strength = Number(value);
});
gui
.add(params, "radius", 0.0, 1.0)
.step(0.01)
.onChange(function (value) {
bloomPass.radius = Number(value);
});
const toneMappingFolder = gui.addFolder("tone mapping");
toneMappingFolder
.add(params, "exposure", 0.1, 2)
.onChange(function (value) {
renderer.toneMappingExposure = Math.pow(value, 4.0);
});
window.addEventListener("resize", onWindowResize);
}
function onWindowResize() {
const width = window.innerWidth;
const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
composer.setSize(width, height);
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
stats.update();
// 通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>
</body>
</html>
main.css
body {
margin: 0;
background-color: #000;
color: #fff;
font-family: Monospace;
font-size: 13px;
line-height: 24px;
overscroll-behavior: none;
}
a {
color: #ff0;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
cursor: pointer;
text-transform: uppercase;
}
#info {
position: absolute;
top: 0px;
width: 100%;
padding: 10px;
box-sizing: border-box;
text-align: center;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
z-index: 1; /* TODO Solve this in HTML */
}
a, button, input, select {
pointer-events: auto;
}
.lil-gui {
z-index: 2 !important; /* TODO Solve this in HTML */
}
@media all and ( max-width: 640px ) {
.lil-gui.root {
right: auto;
top: auto;
max-height: 50%;
max-width: 80%;
bottom: 0;
left: 0;
}
}
#overlay {
position: absolute;
font-size: 16px;
z-index: 2;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(0,0,0,0.7);
}
#overlay button {
background: transparent;
border: 0;
border: 1px solid rgb(255, 255, 255);
border-radius: 4px;
color: #ffffff;
padding: 12px 18px;
text-transform: uppercase;
cursor: pointer;
}
#notSupported {
width: 50%;
margin: auto;
background-color: #f00;
margin-top: 20px;
padding: 10px;
}
总结
通过本教程,我想现在你对效果合成器一定有了更深入的了解,现在,我们在看看官网的定义:
用于在three.js中实现后期处理效果。该类管理了产生最终视觉效果的后期处理过程链。 后期处理过程根据它们添加/插入的顺序来执行,最后一个过程会被自动渲染到屏幕上
结合代码,我想现在理解其它非常容易
<script type="module">
import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
function init() {
// 1【渲染开始】创建了一个RenderPass对象,用于将场景渲染到纹理上。
const renderScene = new RenderPass(scene, camera);
// 2【需要合成的中间特效】创建了一个UnrealBloomPass对象,用于实现辉光效果。≈
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.4,
0.85
);
// 【特效设置】设置发光参数,阈值、强度和半径。
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;
// 3【效果输出】创建了一个OutputPass对象,用于将最终渲染结果输出到屏幕上。
const outputPass = new OutputPass();
// 4【特效合并】创建了一个EffectComposer对象,并将RenderPass、UnrealBloomPass和OutputPass添加到渲染通道中。
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(outputPass);
}
function animate() {
requestAnimationFrame(animate);
// 5【渲染特效】通过调用 render 方法,将场景渲染到屏幕上。
composer.render();
}
</script>
来源:juejin.cn/post/7355055084822446095
go的生态真的一言难尽
前言
标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 Maven 或 Gradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设计和利用现有的工具与库,可以实现一个功能强大且灵活的工具。
正文分为两部分:项目本身和如何使用
一、项目本身
1. 项目需求分析
核心需求
- 依赖管理:
- 解析和下载 Go 项目的依赖。
- 支持依赖版本控制和冲突解决。
- 构建管理:
- 支持编译 Go 项目。
- 支持跨平台编译。
- 支持自定义构建选项。
- 发布管理:
- 打包构建结果。
- 支持发布到不同的平台(如 Docker Hub、GitHub Releases)。
- 任务管理:
- 支持定义和执行自定义任务(如运行测试、生成文档)。
- 插件系统:
- 支持扩展工具的功能。
可选需求
- 缓存机制:缓存依赖和构建结果以提升速度。
- 并行执行:支持并行下载和编译。
2. 技术选型
2.1 编程语言
- Go 语言:由于我们要构建的是 Go 项目的构建工具,选择 Go 语言本身作为开发语言是合理的。
2.2 依赖管理
- Go Modules:Go 自带的依赖管理工具已经很好地解决了依赖管理的问题,可以直接利用 Go Modules 来解析和管理依赖。
2.3 构建工具
- Go 标准库:Go 的标准库提供了强大的编译和构建功能(如
go build
,go install
等命令),可以通过调用这些命令或直接使用相关库来进行构建。
2.4 发布工具
- Docker:对于发布管理,可能需要集成 Docker 来构建和发布 Docker 镜像。
- upx:用于压缩可执行文件。
2.5 配置文件格式
- YAML 或 TOML:选择一种易于阅读和编写的配置文件格式,如 YAML 或 TOML。
3. 系统架构设计
3.1 模块划分
- 依赖管理模块:
- 负责解析和下载项目的依赖。
- 构建管理模块:
- 负责编译 Go 项目,支持跨平台编译和自定义构建选项。
- 发布管理模块:
- 负责将构建结果打包和发布到不同平台。
- 任务管理模块:
- 负责定义和执行自定义任务。
- 插件系统:
- 提供扩展点,允许用户编写插件来扩展工具的功能。
3.2 系统流程
- 初始化项目:读取配置文件,初始化项目环境。
- 依赖管理:解析依赖并下载。
- 构建项目:根据配置文件进行项目构建。
- 执行任务:执行用户定义的任务(如测试)。
- 发布项目:打包构建结果并发布到指定平台。
4. 模块详细设计与实现
4.1 依赖管理模块
4.1.1 设计
利用 Go Modules 现有的功能来管理依赖。可以通过 go list
命令来获取项目的依赖:
4.1.2 实现
package dependency
import (
"fmt"
"os/exec"
)
// ListDependencies 列出项目所有依赖
func ListDependencies() ([]byte, error) {
cmd := exec.Command("go", "list", "-m", "all")
return cmd.Output()
}
// DownloadDependencies 下载项目所有依赖
func DownloadDependencies() error {
cmd := exec.Command("go", "mod", "download")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("download failed: %s", output)
}
return nil
}
4.2 构建管理模块
4.2.1 设计
调用 Go 编译器进行构建,支持跨平台编译和自定义构建选项。
4.2.2 实现
package build
import (
"fmt"
"os/exec"
"runtime"
"path/filepath"
)
// BuildProject 构建项目
func BuildProject(outputDir string) error {
// 设置跨平台编译参数
var goos, goarch string
switch runtime.GOOS {
case "windows":
goos = "windows"
case "linux":
goos = "linux"
default:
goos = runtime.GOOS
}
goarch = "amd64"
output := filepath.Join(outputDir, "myapp")
cmd := exec.Command("go", "build", "-o", output, "-ldflags", "-X main.version=1.0.0")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build failed: %s", output)
}
fmt.Println("Build successful")
return nil
}
4.3 发布管理模块
4.3.1 设计
打包构建结果并发布到不同平台。例如,构建 Docker 镜像并发布到 Docker Hub。
4.3.2 实现
package release
import (
"fmt"
"os/exec"
)
// BuildDockerImage 构建 Docker 镜像
func BuildDockerImage(tag string) error {
cmd := exec.Command("docker", "build", "-t", tag, ".")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker build failed: %s", output)
}
fmt.Println("Docker image built successfully")
return nil
}
// PushDockerImage 推送 Docker 镜像
func PushDockerImage(tag string) error {
cmd := exec.Command("docker", "push", tag)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker push failed: %s", output)
}
fmt.Println("Docker image pushed successfully")
return nil
}
5. 任务管理模块
允许用户定义和执行自定义任务:
package task
import (
"fmt"
"os/exec"
)
type Task func() error
func RunTask(name string, task Task) {
fmt.Println("Running task:", name)
err := task()
if err != nil {
fmt.Println("Task failed:", err)
return
}
fmt.Println("Task completed:", name)
}
func TestTask() error {
cmd := exec.Command("go", "test", "./...")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tests failed: %s", output)
}
fmt.Println("Tests passed")
return nil
}
6. 插件系统
可以通过动态加载外部插件或使用 Go 插件机制来实现插件系统:
package plugin
import (
"fmt"
"plugin"
)
type Plugin interface {
Run() error
}
func LoadPlugin(path string) (Plugin, error) {
p, err := plugin.Open(path)
if err != nil {
return nil, err
}
symbol, err := p.Lookup("PluginImpl")
if err != nil {
return nil, err
}
impl, ok := symbol.(Plugin)
if !ok {
return nil, fmt.Errorf("unexpected type from module symbol")
}
return impl, nil
}
5. 示例配置文件
使用 YAML 作为配置文件格式,定义项目的构建和发布选项:
name: myapp
version: 1.0.0
build:
options:
- -ldflags
- "-X main.version=1.0.0"
release:
docker:
image: myapp:latest
tag: v1.0.0
tasks:
- name: test
command: go test ./...
6. 持续改进
后续我将持续改进工具的功能和性能,例如:
- 增加更多的构建和发布选项。
- 优化依赖管理和冲突解决算法。
- 提供更丰富的插件。
二、如何使用
1. 安装构建工具
我已经将构建工具发布到 GitHub 并提供了可执行文件,用户可以通过以下方式安装该工具。
1.1 使用安装脚本安装
我将提供一个简单的安装脚本,开发者可以通过 curl
或 wget
安装构建工具。
使用 curl
安装
curl -L https://github.com/yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
使用 wget
安装
wget -qO- https://github.com//yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
1.2 手动下载并安装
如果你不想使用自动安装脚本,可以直接从 GitHub Releases 页面手动下载适合你操作系统的二进制文件。
- 访问 GitHub Releases 页面。
- 下载适合你操作系统的二进制文件:
- Linux:
GoForge-linux-amd64
- macOS:
GoForge-darwin-amd64
- Windows:
GoForge-windows-amd64.exe
- Linux:
- 将下载的二进制文件移动到系统的 PATH 路径(如
/usr/local/bin/
),并确保文件有执行权限。
# 以 Linux 系统为例
mv GoForge-linux-amd64 /usr/local/bin/GoForge
chmod +x /usr/local/bin/GoForge
2. 创建 Go 项目并配置构建工具
2.1 初始化 Go 项目
假设你已经有一个 Go 项目或你想创建一个新的 Go 项目。首先,初始化 Go 模块:
mkdir my-go-project
cd my-go-project
go mod init github.com/myuser/my-go-project
2.2 创建 build.yaml
文件
在项目根目录下创建 build.yaml
文件,这个文件类似于 Maven 的 pom.xml
或 Gradle 的 build.gradle
,用于配置项目的依赖、构建任务和发布任务。
示例 build.yaml
project:
name: my-go-project
version: 1.0.0
dependencies:
- name: github.com/gin-gonic/gin
version: v1.7.7
- name: github.com/stretchr/testify
version: v1.7.0
build:
output: bin/my-go-app
commands:
- go build -o bin/my-go-app main.go
tasks:
clean:
command: rm -rf bin/
test:
command: go test ./...
build:
dependsOn:
- test
command: go build -o bin/my-go-app main.go
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
配置说明:
- project: 定义项目名称和版本。
- dependencies: 列出项目的依赖包及其版本号。
- build: 定义构建输出路径和构建命令。
- tasks: 用户可以定义自定义任务(如
clean
、test
、build
等),并可以配置任务依赖关系。 - publish: 定义发布到 GitHub 的配置,包括发布的仓库和需要发布的二进制文件。
3. 执行构建任务
构建工具允许你通过命令行执行各种任务,如构建、测试、清理、发布等。以下是一些常用的命令。
3.1 构建项目
执行以下命令来构建项目。该命令会根据 build.yaml
文件中定义的 build
任务进行构建,并生成二进制文件到指定的 output
目录。
GoForge build
构建过程会自动执行依赖任务(例如 test
任务),确保在构建之前所有测试通过。
3.2 运行测试
如果你想单独运行测试,可以使用以下命令:
GoForge test
这将执行 go test ./...
,并运行所有测试文件。
3.3 清理构建产物
如果你想删除构建生成的二进制文件等产物,可以运行 clean
任务:
GoForge clean
这会执行 rm -rf bin/
,清理 bin/
目录下的所有文件。
3.4 列出所有可用任务
如果你想查看所有可用的任务,可以运行:
GoForge tasks
这会列出 build.yaml
文件中定义的所有任务,并显示它们的依赖关系。
4. 依赖管理
构建工具会根据 build.yaml
中的 dependencies
部分来处理 Go 项目的依赖。
4.1 安装依赖
当执行构建任务时,工具会自动解析依赖并安装指定的第三方库(类似于 go mod tidy
)。
你也可以单独运行以下命令来手动处理依赖:
GoForge deps
4.2 更新依赖
如果你需要更新依赖版本,可以在 build.yaml
中手动更改依赖的版本号,然后运行 mybuild deps
来更新依赖。
5. 发布项目
构建工具提供了发布项目到 GitHub 等平台的功能。根据 build.yaml
中的 publish
配置,你可以将项目的构建产物发布到 GitHub Releases。
5.1 配置发布相关信息
确保你在 build.yaml
中正确配置了发布信息:
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
- type: 发布的目标平台(GitHub 等)。
- repo: GitHub 仓库路径。
- token: 需要设置环境变量
GITHUB_TOKEN
,用于认证 GitHub API。 - assets: 指定发布时需要上传的二进制文件。
5.2 发布项目
确保你已经完成构建,并且生成了二进制文件。然后,你可以执行以下命令来发布项目:
GoForge publish
这会将 bin/my-go-app
上传到 GitHub Releases,并生成一个新的发布版本。
5.3 测试发布(Dry Run)
如果你想在发布之前测试发布流程(不上传文件),可以使用 --dry-run
选项:
GoForge publish --dry-run
这会模拟发布过程,但不会实际上传文件。
6. 高级功能
6.1 增量构建
构建工具支持增量构建,如果你在 build.yaml
中启用了增量构建功能,工具会根据文件的修改时间戳或内容哈希来判断是否需要重新构建未被修改的部分。
build:
output: bin/my-go-app
incremental: true
commands:
- go build -o bin/my-go-app main.go
6.2 插件机制
你可以通过插件机制来扩展构建工具的功能。例如,你可以为工具增加自定义的任务逻辑,或在构建生命周期的不同阶段插入钩子。
在 build.yaml
中定义插件:
plugins:
- name: custom-task
path: plugins/custom-task.go
编写 custom-task.go
,并实现你需要的功能。
7. 调试和日志
如果你在使用时遇到了问题,可以通过以下方式启用调试模式,查看详细的日志输出:
GoForge --debug build
这会输出工具在执行任务时的每一步详细日志,帮助你定位问题。
总结
通过这个构建工具,你可以轻松管理 Go 项目的依赖、构建过程和发布任务。以下是使用步骤的简要总结:
- 安装构建工具:使用安装脚本或手动下载二进制文件。
- 配置项目:创建
build.yaml
文件,定义依赖、构建任务和发布任务。 - 执行任务:通过命令行执行构建、测试、清理等任务。
- 发布项目:将项目的构建产物发布到 GitHub 或其他平台。
来源:juejin.cn/post/7431545806085423158
白嫖微信OCR,实现批量提取图片中的文字
微信自带的OCR使用比较方便,且准确率较高,但是唯一不足的是需要手动截图之后再识别,无法批量操作,这里我们借助wechat-ocr这一开源工具,实现批量读取文件夹下的所有图片并提取文本的功能。下面介绍下操作步骤。
1. 安装wechat-ocr这个库
这里我们使用的是GoBot这一自动化工具(如对该软件感兴趣,可以关注公众号:RPA二师兄),他提供的可视化安装依赖的功能。打开依赖包管理的Tab页,在Python包名称处填写wechat-ocr,然后点击安装,就能完成wechat-ocr的安装,安装完成之后可以切换到管理已安装模块的Tab,可以看到已经成功安装。
2. 编写调用代码
这里我们直接给出代码,只需要创建一个代码流程,将我们给的代码复制进去就可以了。
from package import variables as glv #全局变量,例如glv['test']
from robot_base import log_util
import robot_basic
from robot_base import log_util
import os
import re
from wechat_ocr.ocr_manager import OcrManager, OCR_MAX_TASK_ID
def main(args):
#输入参数使用示例
# if args is :
# 输入参数1 = ""
#else:
# 输入参数1 = args.get("输入参数1", "")
log_util.Logger.info(args)
init_ocr_manger(args['微信安装目录'])
ocr_manager.DoOCRTask(args['待识别图片路径'])
while ocr_manager.m_task_id.qsize() != OCR_MAX_TASK_ID:
pass
global ocr_result
return ocr_result
ocr_result = {}
ocr_manager =
def ocr_result_callback(img_path:str, results:dict):
log_util.Logger.info(results)
ocr_result = results
def init_ocr_manger(wechat_dir):
wechat_dir = find_wechat_path(wechat_dir)
wechat_ocr_dir = find_wechatocr_exe()
global ocr_manager
if ocr_manager is :
ocr_manager = OcrManager(wechat_dir)
# 设置WeChatOcr目录
ocr_manager.SetExePath(wechat_ocr_dir)
# 设置微信所在路径
ocr_manager.SetUsrLibDir(wechat_dir)
# 设置ocr识别结果的回调函数
ocr_manager.SetOcrResultCallback(ocr_result_callback)
# 启动ocr服务
ocr_manager.StartWeChatOCR()
def find_wechat_path(wechat_dir):
# 定义匹配版本号文件夹的正则表达式
version_pattern = re.compile(r'\[\d+\.\d+\.\d+\.\d+\]')
path_temp = os.listdir(wechat_dir)
for temp in path_temp:
# 下载是正则匹配到[3.9.10.27]
# 使用正则表达式匹配版本号文件夹
if version_pattern.match(temp):
wechat_path = os.path.join(wechat_dir, temp)
if os.path.isdir(wechat_path):
return wechat_path
def find_wechatocr_exe():
# 获取APPDATA路径
appdata_path = os.getenv("APPDATA")
if not appdata_path:
print("APPDATA environment variable not found.")
return
# 定义WeChatOCR的基本路径
base_path = os.path.join(appdata_path, r"Tencent\WeChat\XPlugin\Plugins\WeChatOCR")
# 定义匹配版本号文件夹的正则表达式
version_pattern = re.compile(r'\d+')
try:
# 获取路径下的所有文件夹
path_temp = os.listdir(base_path)
except FileNotFoundError:
print(f"The path {base_path} does not exist.")
return
for temp in path_temp:
# 使用正则表达式匹配版本号文件夹
if version_pattern.match(temp):
wechatocr_path = os.path.join(base_path, temp, 'extracted', 'WeChatOCR.exe')
if os.path.isfile(wechatocr_path):
return wechatocr_path
# 如果没有找到匹配的文件夹,返回
return
然后点击流程参数,创建流程的输入参数
3. 调用OCR识别的方法,实现批量的文字提取
使用调用流程组件,填写对应的参数,即可实现图片文字的提取了。
来源:juejin.cn/post/7432193949765287962
uni-app的这个“地雷”坑,我踩了
距离上次的 uni-app-x 文章已有一月有余,在此期间笔者又“拥抱”了 uni-app,使用 uni-app 开发微信小程序。
与使用 uni-app-x 相比个人感觉 uni-app 在开发体验上更流畅,更舒服一些,这可能得益于 uni-app 相对成熟,且与标准的前端开发相差不大。至少在 IDE 的选择上比较自由,比如可以选择 VSCode 或者 WebStorm,笔者习惯了 Jetbrains 家的 IDE,自然选择了 WebStorm。
虽说 uni-app 相对成熟,但是笔者还是踩到了“地雷式”的巨坑,下面且听我娓娓道来。
附:配套代码。
什么样的坑
先描述下是什么样的坑。简单来说,我有一个动态的 style 样式,伪代码如下:
<view v-for="(c, ci) in 10" :key="ci" :style="{ height: `${50}px` }">
{{ c }}
</view>
理论上编译到小程序应该如下:
<view style="height: 50px">1</view>
但是,实际上编译后却是:
<view style="height: [object Object]">1</view>
最后导致样式没有生效。
着手排查
先网上搜索一番,基本上千篇一律的都是 uni-app 编程成微信小程序时 style 不支持对象的形式,需要在对象外包一层数组,需要做如下修改:
<view :style="[{ height: `${50}px` }]"></view>
但是,这种方式对我无效。
然后开始了漫长的排查之旅,对比之前的项目是使用的对象形式对动态 style 进行的赋值也没有遇到这样问题,最后各种尝试至深夜一点多也没有解决,浪费我大好的“青春”。
没有解决问题实在是不甘心啊,于是第二天上午继续排查,观察 git 提交记录,没有发现什么异常的代码,然后开始拉新分支一个一个的 commit 回滚代码,然后再把回滚的代码手敲一遍再一点点的编译调试对比,这真的是浪费时间与精力的一件事,最终也是功夫不负有心人,终于锁定了一个 commit 提交,在这个 commit 后出现了上述问题。
为什么要回滚代码?因为在之前的代码中都是以对象形式为动态 style 赋值的。
现在可以着重的“攻击”这个 commit 上的代码了,仿佛沉溺在水中马上就要浮出水面可以呼一口气。这个 commit 上的代码不是很多,其中就包含上述的伪代码。最后,经过仔细的审查这个 commit 上的代码也没有发现什么异常的代码逻辑,好像突然没有了力气又慢慢沉入了水底。
反正是经过了各种尝试,其中历程真是一把鼻涕一把泪,不提也罢。
也不知是脑子不好使还是最后的倔强,突发奇想的修改了上述伪代码中 v-for
语句中的 c
变量命名:
<view v-for="(a, ci) in 10" :key="ci" :style="{ height: `${50}px` }">
{{ a }}
</view>
妈耶,奇迹发生了,动态 style 编译后正常了,样式生效了。随后又测试了一些其他的命名,如:A,b,B,C,d,D,i,I
,这些都编译后正常,唯独命名为小写的 c
后,编译后不正常还是 [object Object]
的形式。
如果,现在,你迫不及待的去新建个 uni-app 项目来验证笔者所言是否属实,那么不好意思,你大概率不会踩到这个坑。
但是,如果你在动态 style 中再多加一个 css 属性,代码如下:
<view
v-for="(c, ci) in 5"
:key="ci"
:style="{
height: `${50}px`,
marginTop: `${10}px`,
}"
>
{{ c }}
</view>
那么你会发现第一个 height
属性生效了,然而新加的 marginTop
属性却是 [object Object]
。
如果你再多加几个属性,你会发现它们都生效了,唯独第二个属性是失效的。
如果你在这个问题 view
代码前面使用过 v-for
且使用过动态 style 且动态 style 中有字符串模板,那么你会发现问题 view
变正常了。
总结
本文记录了笔者排查 uni-app 动态 style 失效的心路历程,虽然问题得到了解决,但是没有深入研究产生此问题的本质原因,总结起来就是菜,还得多练。
深夜对着星空感叹,这种坑也能被我踩到,真是时也命也。
来源:juejin.cn/post/7416554802254364708
写了一个字典hook,瞬间让组员开发效率提高20%!!!
1、引言
在项目的开发中,发现有很多地方(如:选择器、数据回显等)都需要用到字典数据,而且一个页面通常需要请求多个字典接口,如果每次打开同一个页面都需要重新去请求相同的数据,不但浪费网络资源、也给开发人员造成一定的工作负担。最近在用 taro + react 开发一个小程序,所以就写一个字典 hook 方便大家开发。
2、实现过程
首先,字典接口返回的数据类型如下图所示:
其次,在没有实现字典 hook 之前,是这样使用 选择器 组件的:
const [unitOptions, setUnitOptions] = useState([])
useEffect(() => {
dictAppGetOptionsList(['DEV_TYPE']).then((res: any) => {
let _data = res.rows.map(item => {
return {
label: item.fullName,
value: item.id
}
})
setUnitOptions(_data)
})
}, [])
const popup = (
<PickSelect
defaultValue=""
open={unitOpen}
options={unitOptions}
onCancel={() => setUnitOpen(false)}
onClose={() => setUnitOpen(false)}
/>
)
每次都需要在页面组件中请求到字典数据提供给 PickSelect 组件的 options 属性,如果有多个 PickSelect 组件,那就需要请求多次接口,非常麻烦!!!!!
既然字典接口返回的数据格式是一样的,那能不能写一个 hook 接收不同属性,返回不同字典数据呢,而且还能 缓存 请求过的字典数据?
当然是可以的!!!
预想一下如何使用这个字典 hook?
const { list } = useDictionary('DEV_TYPE')
const { label } = useDictionary('DEV_TYPE', 1)
const { label } = useDictionary('DEV_TYPE', 1, '、')
从上面代码中可以看到,第一个参数接收字典名称,第二个参数接收字典对应的值,第三个参数接收分隔符,而且后两个参数是可选的,因此根据上面的用法来写我们的字典 hook 代码。
interface dictOptionsProps {
label: string | number;
value: string | number | boolean | object;
disabled?: boolean;
}
interface DictResponse {
value: string;
list: dictOptionsProps[];
getDictValue: (value: string) => string
}
let timer = null;
const types: string[] = [];
const dict: Record<string, dictOptionsProps[]> = {}; // 字典缓存
// 因为接收不同参数,很适合用函数重载
function useDictionary(type: string): DictResponse;
function useDictionary(
type: string | dictOptionsProps[],
value: number | string | Array<number | string>,
separator?: string
): DictResponse;
function useDictionary(
type: string | dictOptionsProps[],
value?: number | string | Array<string | number>,
separator = ","
): DictResponse {
const [options, setOptions] = useState<dictOptionsProps[]>([]); // 字典数组
const [dictValue, setDictValue] = useState(""); // 字典对应值
const init = () => {
if (!dict[type] || !dict[type].length) {
dict[type] = [];
types.push(type);
// 当多次调用hook时,获取所有参数,合成数组,再去请求,这样可以避免多次调用接口。
timer && clearTimeout(timer);
timer = setTimeout(() => {
dictAppGetOptionsList(types.slice()).then((res) => {
for (const key in dictRes.data) {
const dictList = dictRes.data[key].map((v) => ({
label: v.description,
value: v.subtype,
}));
dict[type] = dictList
setOptions(dictList) // 注意这里会有bug,后面有说明的
}
});
}, 10);
} else {
typeof type === "string" ? setOptions(dict[type]) : setOptions(type);
}
};
// 获取字典对应值的中文名称
const getLabel = useCallback(
(value) => {
if (value === undefined || value === null || !options.length) return "";
const values = Array.isArray(value) ? value : [value];
const items = values.map((v) => {
if (typeof v === "number") v = v.toString();
return options.find((item) => item.value === v) || { label: value };
});
return items.map((v) => v.label).join(separator);
},
[options]
)
useEffect(() => init(), [])
useEffect(() => setDictValue(getLabel(value)), [options, value])
return { value: dictValue, list: options, getDictValue: getLabel };
}
初步的字典hook已经开发完成,在 Input 组件中添加 dict 属性,去试试看效果如何。
export interface IProps extends taroInputProps {
value?: any;
dict?: string; // 字典名称
}
const CnInput = ({
dict,
value,
...props
}: IProps) => {
const { value: _value } = dict ? useDictionary(dict, value) : { value };
return <Input value={_value} {...props} />
}
添加完成,然后去调用 Input 组件
<CnInput
readonly
dict="DEV_ACCES_TYPE"
value={formData?.accesType}
/>
<CnInput
readonly
dict="DEV_SOURCE"
value={formData?.devSource}
/>
没想到,翻车了
会发现,在一个页面组件中,多次调用 Input 组件,只有最后一个 Input 组件才会回显数据
这个bug是怎么出现的呢?原来是 setTimeout 搞的鬼,在 useDictionary hook 中,当多次调用 useDictionary hook 的时候,为了能拿到全部的 type 值,请求一次接口拿到所有字典的数据,就把字典接口放在 setTimeout 里,弄成异步的逻辑。但是每次调用都会清除上一次的 setTimeout,只保存了最后一次调用 useDictionary 的 setTimeout ,所以就会出现上面的bug了。
既然知道问题所在,那就知道怎么去解决了。
解决方案: 因为只有调用 setOptions 才会引起页面刷新,为了不让 setTimeout 清除掉 setOptions,就把 setOptions 添加到一个更新队列中,等字典接口数据回来再去执行更新队列就可以了。
let timer = null;
const queue = []; // 更新队列
const types: string[] = [];
const dict: Record<string, dictOptionsProps[]> = {};
function useDictionary2(type: string): DictResponse;
function useDictionary2(
type: string | dictOptionsProps[],
value: number | string | Array<number | string>,
separator?: string
): DictResponse;
function useDictionary2(
type: string | dictOptionsProps[],
value?: number | string | Array<string | number>,
separator = ","
): DictResponse {
const [options, setOptions] = useState<dictOptionsProps[]>([]);
const [dictValue, setDictValue] = useState("");
const getLabel = useCallback(
(value) => {
if (value === undefined || value === null || !options.length) return "";
const values = Array.isArray(value) ? value : [value];
const items = values.map((v) => {
if (typeof v === "number") v = v.toString();
return options.find((item) => item.value === v) || { label: value };
});
return items.map((v) => v.label).join(separator);
},
[options]
);
const init = () => {
if (typeof type === "string") {
if (!dict[type] || !dict[type].length) {
dict[type] = [];
const item = {
key: type,
exeFunc: () => {
if (typeof type === "string") {
setOptions(dict[type]);
} else {
setOptions(type);
}
},
};
queue.push(item); // 把 setOptions 推到 更新队列(queue)中
types.push(type);
timer && clearTimeout(timer);
timer = setTimeout(async () => {
const params = types.slice();
types.length = 0;
try {
let dictRes = await dictAppGetOptionsList(params);
for (const key in dictRes.data) {
dict[key] = dictRes.data[key].map((v) => ({
label: v.description,
value: v.subtype,
}));
}
queue.forEach((item) => item.exeFunc()); // 接口回来了再执行更新队列
queue.length = 0; // 清空更新队列
} catch (error) {
queue.length = 0;
}
}, 10);
} else {
typeof type === "string" ? setOptions(dict[type]) : setOptions(type);
}
}
};
useEffect(() => init(), []);
useEffect(() => setDictValue(getLabel(value)), [options, value]);
return { value: dictValue, list: options, getDictValue: getLabel };
}
export default useDictionary;
修复完成,再去试试看~
不错不错,已经修复,嘿嘿~
这样就可以愉快的使用 字典 hook 啦,去改造一下 PickSelect 组件
export interface IProps extends PickerProps {
open: boolean;
dict?: string;
options?: dictOptionsProps[];
onClose: () => void;
}
const Base = ({
dict,
open = false,
options = [],
onClose = () => { },
...props
}: Partial<IProps>) => {
// 如果不传 dict ,就拿 options
const { list: _options } = dict ? useDictionary(dict) : { list: options };
return <Picker.Column>
{_options.map((item) => {
return (
<Picker.Option
value={item.value}
key={item.value as string | number}
>
{item.label}
</Picker.Option>
);
})}
</Picker.Column>
在页面组件调用 PickSelect 组件
效果:
这样就只需要传入 dict 值,就可以轻轻松松获取到字典数据啦。不用再手动去调用字典接口啦,省下来的时间又可以愉快的摸鱼咯,哈哈哈
最近也在写 vue3 的项目,用 vue3 也实现一个吧。
// 定时器
let timer = 0
const timeout = 10
// 字典类型缓存
const types: string[] = []
// 响应式的字典对象
const dict: Record<string, Ref<CnPage.OptionProps[]>> = {}
// 请求字典选项
function useDictionary(type: string): Ref<CnPage.OptionProps[]>
// 解析字典选项,可以传入已有字典解析
function useDictionary(
type: string | CnPage.OptionProps[],
value: number | string | Array<number | string>,
separator?: string
): ComputedRef<string>
function useDictionary(
type: string | CnPage.OptionProps[],
value?: number | string | Array<number | string>,
separator = ','
): Ref<CnPage.OptionProps[]> | ComputedRef<string> {
// 请求接口,获取字典
if (typeof type === 'string') {
if (!dict[type]) {
dict[type] = ref<CnPage.OptionProps[]>([])
if (type === 'UNIT_LIST') {
// 单位列表调单独接口获取
getUnitListDict()
} else if (type === 'UNIT_TYPE') {
// 单位类型调单独接口获取
getUnitTypeDict()
} else {
types.push(type)
}
}
// 等一下,人齐了才发车
timer && clearTimeout(timer)
timer = setTimeout(() => {
if (types.length === 0) return
const newTypes = types.slice()
types.length = 0
getDictionary(newTypes).then((res) => {
for (const key in res.data) {
dict[key].value = res.data[key].map((v) => ({
label: v.description,
value: v.subtype
}))
}
})
}, timeout)
}
const options = typeof type === 'string' ? dict[type] : ref(type)
const label = computed(() => {
if (value === undefined || value === null) return ''
const values = Array.isArray(value) ? value : [value]
const items = values.map(
(value) => {
if (typeof value === 'number') value = value.toString()
return options.value.find((v) => v.value === value) || { label: value }
}
)
return items.map((v) => v.label).join(separator)
})
return value === undefined ? options : label
}
export default useDictionary
感觉 vue3 更简单啊!
到此结束!如果有错误,欢迎大佬指正~
来源:juejin.cn/post/7377559533785022527
什么?Flutter 又要凉了? Flock 是什么东西?
今天突然看到这个消息,突然又有一种熟悉的味道,看来这个月 Flutter “又要凉一次了”:
起因 flutter foundation 决定 fork Flutter 并推出 Flock 分支用于自建维护,理由是:
foundation 推测 Flutter 团队的劳动力短缺,因为 Flutter 需要维护 Android、iOS、Mac、Window、Linux、Web 等平台,但是 Flutter团队的规模仅略有增加。
在 foundation 看来,保守估计全球至少有 100 万 Flutter 相关开发者,而 Flutter 团队的规模大概就只有 50+ 人,这个比例并不健康。
问题在于这个数据推测就很迷,没有数据来源的推测貌似全靠“我认为”。。。。
另外 foundation 做这个决定,还因为 Flutter 官方团队对其 6 个支持的平台中,有 3 个处于维护模式(Window、Mac、Linux),所以他们无法接受桌面端的现场,因为他们认为桌面端很可能是 Flutter 最大的未开发价值。
关于这点目前 PC 端支持确实缓慢,但也并没有完全停止,如果关注 PC issue 的应该看到, Mac 的 PlatformView 和 WebView 支持近期才初步落地。
而让 foundation 最无法忍受的是,issue 的处理还有 pr 的 merge 有时候甚至可能会积累数年之久。
事实上这点确实成立,因为 Flutter 在很多功能上都十分保守,同时 issue 量大且各平台需求多等原因,很多能力的支持时间跨度多比较长,例如 「Row/Column 即将支持 Flex.spacing」 、「宏编程支持」 、「支持 P3 色域」 等这些都是持续了很久才被 merge 的 feature 。
所以 Flutter 的另外一个支持途径是来自社区 PR,但是 foundation 表示 Flutter 的代码 Review 和审计工作缓慢,并且沟通困难,想法很难被认可等,让 foundation 无法和 Flutter 官方有效沟通。
总结起来,在 foundation 的角度是,Flutter 官方团队维护 Flutter 不够尽心尽力。
所以他们决定,创建 Flutter 分支,并称为 Flock:意寓位 “Flutter+”。
不过 foundation 表示,他们其实并不想也不打算分叉 Flutter 社区,Flock 将始终与 Flutter 保持同步。
Flock 的重点是添加重要的错误修复和全新的社区功能支持,例如 Flutter 团队不做的,或者短期不会实现:
并且 Flock 的目的是招募一个比 Flutter 团队大得多的 PR 审查团队,从而加快 PR 的审计和推进。
所以看起来貌似这是一件好事,那么为什么解读会是“崩盘”和“内斗”?大概还是 Flutter 到时间凉了,毕竟刚刚过完 Flutter 是十周年生日 ,凉一凉也挺好的。
来源:juejin.cn/post/7431032490284236839
BOE(京东方)2024年前三季度净利润三位数增长 “屏之物联”引领企业高质发展
10月30日,京东方科技集团股份有限公司(京东方A:000725;京东方B:200725)发布2024年第三季度报告,前三季度公司实现营业收入1437.32亿元,较去年同期增长13.61%;归属于上市公司股东净利润33.10亿元,同比大幅增长223.80%。其中,第三季度实现营业收入503.45亿元,较去年同期增长8.65%;归属于上市公司股东净利润10.26亿元,同比增长258.21%。BOE(京东方)凭借稳健的经营策略和行业领先的技术优势,在保持半导体显示产业龙头地位的同时,持续推动“1+4+N+生态链”在各个细分市场的深度布局与成果落地,不断深化“屏之物联”战略在多业态场景的转化应用。面向下一个三十年,BOE(京东方)积极推动构建产业发展“第N曲线”,打造新的业务增长极,持续激发产业生态活力。
不仅业绩表现亮眼,BOE(京东方)还不断加大在前沿技术领域和物联网转型方面的投入与探索,为全球显示及物联网产业的未来发展注入新的活力。第三季度,BOE(京东方)全球创新伙伴大会成功举办,全面展示公司在前沿技术领域的重要突破以及物联网转型创新成果,并重磅发布了企业创新发展的战略升维“第N曲线”理论。这一理论不仅承载着企业文化的深厚底蕴,更是对核心优势资源的深度拓展,在“第N曲线”理论指导下,BOE(京东方)已在玻璃基、钙钛矿等新兴领域重点布局,其中,钙钛矿光伏电池中试线从设备搬入到首批样品产出,历时仅38天,创造了行业新记录,这一突破性进展标志着BOE(京东方)在钙钛矿光伏产业化道路上迈出了重要一步,以卓越的实力和高效的速度着力打造“第N曲线”关键增长极,持续引领行业走向智能化、可持续化发展。
稳居半导体显示领域龙头地位,技术创新引领行业发展
2024年前三季度,BOE(京东方)凭借前瞻性的全球市场布局,持续稳固半导体显示领域的龙头地位,不仅专利申请量保持全球领先,更有自主研发的ADS Pro顶尖技术引领行业发展,在柔性AMOLED市场也持续突破,各类技术创新成果丰硕。据市场调研机构Omdia数据显示,BOE(京东方)显示屏整体出货量和五大主流应用领域液晶显示屏出货量稳居全球第一。在专利方面,BOE(京东方)累计自主专利申请已超9万件,其中发明专利超90%,海外专利超30%,技术与产品创新能力稳步提升。同时,BOE(京东方)持续展现强大的创新实力和市场影响力,BOE(京东方)自主研发的、独有的液晶显示领域顶流技术ADS Pro,不仅是目前全球出货量最高的主流液晶显示技术,也是应用最广的硬屏液晶显示技术。凭借高环境光对比度、全视角无色偏、高刷新率和动态画面优化等方面的卓越性能表现,ADS Pro技术成为客户高端旗舰产品的首选,市场出货量和客户采纳度遥遥领先,展现了液晶显示技术蓬勃的生命力,更是极大推动了全球显示产业的良性健康发展。在柔性显示领域,2024年前三季度,BOE(京东方)柔性AMOLED产品出货量进一步增加,荣耀Magic6系列搭载BOE(京东方)首发的OLED低功耗解决方案,开启柔性OLED低功耗全新时代,获得市场和客户的广泛赞誉;与OPPO一加客户联合发布全新2K+LTPO全能高端屏幕标志着柔性显示的又一次全面技术革新,凭借在画质、性能及护眼等多方面的显著提升,再次定义高端柔性OLED屏幕行业新标准。同时,BOE(京东方)加快AMOLED产业布局,投建的国内首条第8.6代AMOLED生产线从开工到封顶仅用183天,以科学、高效、高质的速度树立行业新标杆,推动OLED显示产业快速迈进中尺寸发展阶段。
“1+4+N”业务布局成果显著,打造多元化发展格局
在持续深耕显示行业的同时,BOE(京东方)始终坚持创新发展,“1+4+N+生态链”业务也在创新技术的赋能下展现出全新活力,各个细分市场成果显著。BOE(京东方)物联网创新业务在智慧终端和系统方案两大领域持续高速发展,智慧终端领域,BOE(京东方)正式发布“Smart GOAL”业务目标,致力于打造软硬融合、服务全球、一站式、高效敏捷、绿色低碳的智造体系,并在白板、拼接、电子价签(ESL)等细分市场出货量保持全球第一(数据来源:迪显、Omdia等);系统方案领域,BOE(京东方)持续深耕智慧园区、智慧金融等多个物联网细分场景,积极拓展人机交互协同新边界。在传感业务方面,BOE(京东方)光幕技术及MEMS传感等技术加速赋能奇瑞汽车,推进汽车智能化转型;发布国内首个《乘用车用电子染料液晶调光玻璃技术规范》团体标准,并在智慧视窗领域超额完成极氪首款标配车型调光窗的量产交付,实现订单量增长200%,开启光幕技术创新与应用的新篇章;同时还在工业传感器领域导入6家战略客户,未来将在项目合作及产品研发等方面开展广泛合作。在MLED业务方面, BOE(京东方)MLED珠海项目全面启动,标志着公司在MLED领域进一步深入布局,为全球MLED市场的拓展奠定了坚实基础。在智慧医工业务方面,BOE(京东方)强化科技与医学相结合,打通“防治养”全链条,持续推动医疗健康服务的智慧化升级,成都京东方智慧医养社区正式投入运营,创新医养融合模式,成为BOE(京东方)布局智慧医养领域的重要里程碑;合肥京东方医院加入胸部肿瘤长三角联盟,携手优质专家资源造福当地患者;BOE(京东方)健康研究院与山西省肿瘤医院合作开展NK细胞治疗膀胱癌的临床研究,助力医疗技术创新。
BOE(京东方)还以“N”业务为着力点,为不同行业提供软硬融合的整体解决方案,包括智慧车载、数字艺术、AI+、超高清显示、智慧能源等多个细分领域,打造业务增长新曲线。在车载领域,BOE(京东方)持续保持车载显示屏出货量及出货面积全球第一(数据来源:Omdia),智能座舱产品全面应用到长安汽车、吉利汽车、蔚来、理想等全球各大主流汽车品牌中。在数字艺术领域,艺云科技在裸眼3D显示技术等方面取得新突破,裸眼3D屏亮相国家博物馆,艺云数字艺术中心(王府井)、艺云数字艺术中心(宜宾)正式开馆,创新显示技术为多个领域增光添彩。在AI+领域,BOE(京东方)已将人工智能技术产品、服务、解决方案应用于制造、办公、医疗、零售等细分场景,依托自主研发的人工智能平台及衍生技术集,打造AI良率分析系统、AI显示知识问答系统、显示工业大模型等,大幅度提高生产效率。在超高清领域,BOE(京东方)中联超清通过8K超高清显示技术、超薄全贴合电子站牌和户外LCD广告屏等产品,赋能合肥高新区公交站升级、成都双流国际机场T1航站楼,助力交通出行智能化服务水平大幅提升。在绿色发展方面,BOE(京东方)能源业务在工业、商业、园区等多个场景下加速推进零碳综合能源服务,成功落地多个能源托管项目和碳资产管理项目,助力社会实现二氧化碳减排约33万吨。
值得一提的是,BOE(京东方)在全球化布局与品牌建设的道路上也迈出了更加坚实的步伐。“你好,BOE”、《BOE解忧实验室》两大营销IP持续大热,BOE(京东方)年度标志性品牌活动“你好,BOE”首站亮相海外,助力中国非物质文化遗产艺术展览落地法国巴黎,向世界展示中国科技的创新活力;在上海北外滩盛大启幕的“你好,BOE”SUPER O SPACE影像科技展以“艺术x科技”为主题为观众带来了一场视觉盛宴,成为BOE(京东方)“屏之物联”战略赋能万千应用场景的又一次生动展现;《BOE解忧实验室》奇遇发现季节目以全网4.58亿传播的辉煌成绩,成为2024年度硬核技术科普综艺及科技企业破圈营销典范。2024年是体育大年,在巴黎全球体育盛会举办期间,BOE(京东方)还与联合国教科文组织(UNESCO)在法国巴黎总部签订合作协议,成为首个支持联合国“科学十年”的中国科技企业,更助力中国击剑队出征巴黎,在科技、体育、文化等多个维度树立中国科技企业出海的全新范式。
在新技术、新消费、新场景的多重驱动下,2024年前三季度,BOE(京东方)保持了稳健的发展态势,不断创新前沿技术成果,丰富多元应用场景,为半导体显示产业高质升维发展注入源源不断的动能。未来,BOE(京东方)将继续秉承“屏之物联”战略,以稳健的经营、前瞻性的技术研发和持续应用创新,携手全球合作伙伴共同构建“Powered by BOE”产业价值创新生态,推动显示技术、物联网技术与数字技术的深度融合,为显示行业高质量发展贡献力量,共创智慧美好未来。
BOE(京东方)全新一代发光器件赋能iQOO 13 全面引领柔性显示行业性能新高度
10月30日,备受瞩目的iQOO最新旗舰机——被誉为“性能之光”的iQOO 13在深圳震撼发布。该款机型由BOE(京东方)独供6.82英寸超旗舰2K LTPO直屏,行业首发搭载全新一代Q10发光器件,在画面表现、护眼舒适度及性能功耗方面均达到行业领先水准,并以“直屏超窄边”的设计为用户呈现了前所未有的视觉体验,将直板手机的产品性能推向了全新高度。此次BOE(京东方)携手vivo旗下iQOO品牌联合打造旗舰新品,既体现了以“Powered by BOE”的生态携手合作伙伴联合创新的强大成果,还彰显了BOE(京东方)在柔性显示领域的强大技术优势和引领实力。
在画面表现方面,得益于BOE(京东方)全新发光器件,iQOO 13实现了屏幕性能的全面提升。该款机型全局峰值亮度提升12.5%,可达1800nit,局部峰值亮度更是达到4500nit,即使在强光照射下屏幕内容也清晰可见;在极端温度环境(-10℃至45℃)下,屏幕的偏色现象减少50%以上,确保用户无论身处何种复杂环境下,都能享受到始终一致的卓越屏幕观看体验。此外,iQOO 13屏幕还实现了15%的拖影减轻效果,搭配其高达144Hz的刷新率,无论是观看高清视频还是畅玩大型游戏,都能呈现流畅无阻、细腻入微的视觉效果。
在性能及功耗领域,借助BOE(京东方)领先的屏幕技术加持,iQOO 13的触控技术全新飞跃,实现了更为灵敏的触控体验,响应速度也达到了前所未有的快捷。在确保高度灵敏操作的同时,iQOO 13还通过BOE(京东方)全新迭代升级的Q10核心发光器件,成功将屏幕显示功耗降低10%,寿命提升33%,从而实现续航能力的全面跃升。这一系列改进不仅提升了能效比,更带来了更为精细化的表现,为用户带来更加出色的使用体验。
在护眼舒适度方面,作为首款将2K分辨率与类自然偏光完美结合的产品,iQOO 13的表现同样出色,搭载BOE(京东方)OLED圆偏振光护眼技术,此项技术能够通过模拟自然光线在进入人眼时在多个方向上的均匀分布特性,还原自然光所带来的舒适、健康体验,从而显著减轻长时间使用手机所带来的用眼疲劳问题。同时,iQOO 13还配备BOE(京东方)至高2592Hz全亮度高频PWM调光方案,在各种光线环境下都能确保屏幕的稳定性,可有效降低屏幕频闪对眼睛造成的潜在危害。
根据Omdia数据显示,截至2024年上半年,京东方柔性OLED出货量已连续多年稳居国内第一,全球第二,柔性OLED相关专利申请超3万件。目前,BOE(京东方)柔性显示终端解决方案已应用于多款国内外头部品牌的高端旗舰机型,并持续拓展至笔记本、车载、可穿戴等丰富场景。
作为领先的物联网创新企业,BOE(京东方)多年来始终秉持对技术的尊重和对创新的坚持,通过“Powered by BOE”的生态构建与合作伙伴联合创新,引领中国柔性OLED产业不断迈向新高度。未来,BOE(京东方)将秉持“屏之物联”战略,联动上下游生态链伙伴,共同探索更多柔性显示应用场景,为全球亿万用户带来屏联万物的美好“视”界。
收起阅读 »买了个mini主机当服务器
虽然有苹果的电脑,但是在安装一些软件的时候,总想着能不能有一个小型的服务器,免得各种设置导致 Mac 出现异常。整体上看了一些小型主机,也看过苹果的 Mac mini,但是发现它太贵了,大概要 3000 多,特别是如果要更高配置的话,价格会更高,甚至更贵。所以,我就考虑一些别的小型主机。也看了一些像 NUC 这些服务器,但是觉得还是太贵了。于是我自己去淘宝搜索,找到了这一款 N100 版的主机。
成本的话,由于有折扣,所以大概是 410 左右,然后自己加了个看上去不错的内存条花了 300 左右。硬盘的话我自己之前就有,所以总成本大概是 700 左右。大小的话,大概是一台手机横着和竖着的正方形大小,还带 Wi-Fi,虽然不太稳定。
一、系统的安装
系统我看是支持windows,还有现在Ubuntu,但是我这种选择的是centos stream 9, 10的话我也找过,但是发现很多软件还有不兼容。所以最终还是centos stream 9。
1、下载Ventoy软件
去Ventoy官网下载Ventoy软件(Download . Ventoy)如下图界面
2、制作启动盘
选择合适的版本以及平台下载好之后,进行解压,解压出来之后进入文件夹,如下图左边所示,双击打开Ventoy2Disk.exe,会出现下图右边的界面,选择好自己需要制作启动盘的U盘,然后点击安装等待安装成功即可顺利制作成功启动U盘。
3、centos安装
直接取官网,下载完放到u盘即可。
它的BIOS是按F7启动,直接加载即可。
之后就是正常的centos安装流程了。
二、连接wifi
因为是用作服务器的,所以并没有给它配置个专门的显示器,只要换个网络,就连不上新的wifi了,这里可以用网线连接路由器进行下面的操作即可。
在 CentOS 系统中,通过命令行连接 Wi-Fi 通常需要使用 nmcli(NetworkManager 命令行工具)来管理网络连接。nmcli 是 NetworkManager 的一个命令行接口,可以用于创建、修改、激活和停用网络连接。以下是如何使用 nmcli 命令行工具连接 Wi-Fi 的详细步骤。
步骤 1: 检查网络接口
首先,确认你的 Wi-Fi 网络接口是否被检测到,并且 NetworkManager 是否正在运行。
nmcli device status
输出示例:
DEVICE TYPE STATE CONNECTION
wlp3s0 wifi disconnected --
enp0s25 ethernet connected Wired connection 1
lo loopback unmanaged --
在这个示例中,wlp3s0 是 Wi-Fi 接口,它当前处于未连接状态。
步骤 2: 启用 Wi-Fi 网卡
如果你的 Wi-Fi 网卡是禁用状态,可以通过以下命令启用:
nmcli radio wifi on
验证 Wi-Fi 是否已启用:
nmcli radio
步骤 3: 扫描可用的 Wi-Fi 网络
使用 nmcli 扫描附近的 Wi-Fi 网络:
nmcli device wifi list
你将看到可用的 Wi-Fi 网络列表,每个网络都会显示 SSID(网络名称)、安全类型等信息。
步骤 4: 连接到 Wi-Fi 网络
使用 nmcli 命令连接到指定的 Wi-Fi 网络。例如,如果你的 Wi-Fi 网络名称(SSID)是 MyWiFiNetwork,并且密码是 password123,你可以使用以下命令连接:
nmcli device wifi connect 'xxxxxx' password 'xxxxx'
你应该会看到类似于以下输出,表明连接成功:
Device 'wlp3s0' successfully activated with 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.
步骤 5: 验证连接状态
验证网络连接状态:
nmcli connection show
查看当前连接的详细信息:
nmcli device show wlp3s0
三、VNC远程连接
桌面还是偶尔需要用一下的,虽然用的不多。
root@master:~# dnf install -y tigervnc-server
root@master:~# vncserver
bash: vncserver: command not found...
Install package 'tigervnc-server' to provide command 'vncserver'? [N/y] y
* Waiting in queue...
* Loading list of packages....
The following packages have to be installed:
dbus-x11-1:1.12.20-8.el9.x86_64 X11-requiring add-ons for D-BUS
tigervnc-license-1.14.0-3.el9.noarch License of TigerVNC suite
tigervnc-selinux-1.14.0-3.el9.noarch SELinux module for TigerVNC
tigervnc-server-1.14.0-3.el9.x86_64 A TigerVNC server
tigervnc-server-minimal-1.14.0-3.el9.x86_64 A minimal installation of TigerVNC server
Proceed with changes? [N/y] y
* Waiting in queue...
* Waiting for authentication...
* Waiting in queue...
* Downloading packages...
* Requesting data...
* Testing changes...
* Installing packages...
WARNING: vncserver has been replaced by a systemd unit and is now considered deprecated and removed in upstream.
Please read /usr/share/doc/tigervnc/HOWTO.md for more information.
You will require a password to access your desktops.
getpassword error: Inappropriate ioctl for device
Password:
之后在mac开启屏幕共享就可以了
四、docker 配置
docker安装我以为很简单,没想到这里是最难的一步了。安装完docker之后,总是报错:
Error response from daemon: Get "https://registry-1.docker.io/v2/": context deadline exceeded
即使改了mirrors也毫无作用
{
"registry-mirrors": [
"https://ylce84v9.mirror.aliyuncs.com"
]
}
看起来好像是docker每次pull镜像都要访问一次registry-1.docker.io,但是这个网址国内已经无法连接了,各种折腾,这里只贴一下代码吧,原理就就不讲了(懂得都懂)。
sslocal -c /etc/猫代理.json -d start
curl --socks5 127.0.0.1:1080 http://httpbin.org/ip
sudo yum -y install privoxy
vim /etc/systemd/system/docker.service.d/http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:8118"
/etc/systemd/system/docker.service.d/https-proxy.conf
[Service]
Environment="HTTPS_PROXY=http://127.0.0.1:8118"
最后重启docker
systemctl start privoxy
systemctl enable privoxy
sudo systemctl daemon-reload
sudo systemctl restart docker
五、文件共享
sd卡好像读取不了,只能换个usb转换器
fdisk -l
mount /dev/sdb1 /mnt/usb/sd
在CentOS中设置文件共享,可以使用Samba服务。以下是配置Samba以共享文件的基本步骤:
- 安装Samba
sudo yum install samba samba-client samba-common
- 设置共享目录
编辑Samba配置文件
/etc/samba/smb.conf
,在文件末尾添加以下内容:
[shared]
path = /path/to/shared/directory
writable = yes
browseable = yes
guest ok = yes
- 设置Samba密码
为了允许访问,需要为用户设置一个Samba密码:
sudo smbpasswd -a your_username
- 重启Samba服务
sudo systemctl restart smb.service
sudo systemctl restart nmb.service
- 配置防火墙(如果已启用)
允许Samba通过防火墙:
sudo firewall-cmd --permanent --zone=public --add-service=samba
sudo firewall-cmd --reload
现在,您应该能够从网络上的其他计算机通过SMB/CIFS访问共享。在Windows中,你可以使用\\centos-ip\shared
,在Linux中,你可以使用smbclient //centos-ip/shared -U your_username
参考:
https://猫代理help.github.io/猫代理/linux.html
来源:juejin.cn/post/7430460789067055154
开发小同学的骚操作,还好被我发现了
大家好,我是程序员鱼皮。今天给朋友们还原一个我们团队真实的开发场景。
开发现场
最近我们编程导航网站要开发 用户私信
功能,第一期要做的需求很简单:
- 能让两个用户之间 1 对 1 单独发送消息
- 用户能够查看到消息记录
- 用户能够实时收到消息通知
这其实是一个双向实时通讯的场景,显然可以使用 WebSocket 技术来实现。
团队的后端开发小 c 拿到需求后就去调研了,最后打算采用 Spring Boot Starter 快速整合 Websocket 来实现,接受前端某个用户传来的消息后,转发到接受消息的用户的会话,并在数据库中记录,便于用户查看历史。
小 c 的代码写得还是不错的,用了一些设计模式(像策略模式、工厂模式)对代码进行了一些抽象封装。虽然在我看来对目前的需求来说稍微有点过度设计,但开发同学有自己的理由和想法,表示尊重~
前端同学小 L 也很快完成了开发,并且通过了产品的验收。
看似这个需求就圆满地完成了,但直到我阅读前端同学的代码时,才发现了一个 “坑”。
这是前端同学小 L 提交的私信功能代码,看到这里我就已经发现问题了,朋友们能注意到么?
解释一下,小 L 引入了一个 nanoid
库,这个库的作用是生成唯一 id。看到这里,我本能地感到疑惑:为什么要引入这个库?为什么前端要生成唯一 id?
难道。。。是作为私信消息的 id?
果不其然,通过这个库在前端给每个消息生成了一个唯一 id,然后发送给后端。
后端开发的同学可能会想:一般情况下不都是后端利用数据库的自增来生成唯一 id 并返回给前端嘛,怎么需要让前端来生成呢?
这里小 L 的解释是,在本地创建消息的时候,需要有一个 id 来追踪状态,不会出现消息没有 id 的情况。
首先,这么做的确 能够满足需求 ,所以我还是通过了代码审查;但严格意义上来说,让前端来生成唯一 id 其实不够优雅,可能会有一些问题。
前端生成 id 的问题
1)ID 冲突:同时使用系统的前端用户可能是非常多的,每个用户都是一个客户端,多个前端实例可能会生成相同的 ID,导致数据覆盖或混乱。
2)不够安全:切记,前端是没有办法保证安全性的!因为攻击者可以篡改或伪造请求中的数据,比如构造一个已存在的 id,导致原本的数据被覆盖掉,从而破坏数据的一致性。
要做这件事成本非常低,甚至不需要网络攻击方面的知识,打开 F12 浏览器控制台,重放个请求就行实现:
3)时间戳问题:某些生成 id 的算法是依赖时间戳的,比如当前时间不同,生成的 id 就不同。但是如果前端不同用户的电脑时间不一致,就可能会生成重复 id 或无效 id。比如用户 A 电脑是 9 点时生成了 id = 06030901,另一个用户 B 电脑时间比 A 慢了一个小时,现在是 8 点,等用户 B 电脑时间为 9 点的时候,可能又生成了重复 id = 06030901,导致数据冲突。这也被称为 “分布式系统中的全局时钟问题”。
明确前后端职责
虽然 Nanoid 这个库不依赖时间戳来生成 id,不会受到设备时钟不同步的影响,也不会因为时间戳重复而导致 ID 冲突。根据我查阅的资料,生成大约 10 ^ 9 个 ID 后,重复的可能性大约是 10 ^ -17,几乎可以忽略不计。但一般情况下,我个人会更建议将业务逻辑统一放到后端实现,这么做的好处有很多:
- 后端更容易保证数据的安全性,可以对数据先进行校验再生成 id
- 前端尽量避免进行复杂的计算,而是交给后端,可以提升整体的性能
- 职责分离,前端专注于页面展示,后端专注于业务,而不是双方都要维护一套业务逻辑
我举个典型的例子,比如前端下拉框内要展示一些可选项。由于选项的数量并不多,前端当然可以自己维护这些数据(一般叫做枚举值),但后端也会用到这些枚举值,双方都写一套枚举值,就很容易出现不一致的情况。推荐的做法是,让后端返回枚举值给前端,前端不用重复编写。
所以一般情况下,对于 id 的生成,建议统一交给后端实现,可以用雪花算法根据时间戳生成,也可以利用数据库主键生成自增 id 或 UUID,具体需求具体分析吧~
来源:juejin.cn/post/7376148503087169562
前端大佬都在用的useForm究竟有多强?
大家好,今天我要和大家分享一个超级实用的功能 - alovajs 的 useForm。老实说,当我第一次接触到这个功能时,我简直惊呆了!以前处理表单提交总是让我头疼不已,写了一堆重复的代码还容易出错。但现在有了 useForm,一切都变得如此简单和优雅。让我来告诉你它是如何改变我的开发体验的!
alovajs 简介
首先,让我介绍一下 alovajs。它不仅仅是一个普通的请求工具,而是一个能大大简化我们 API 集成流程的新一代利器。与 react-query 和 swr 这些库不同,alovajs 提供了针对各种请求场景的完整解决方案。
它有 15+ 个"请求策略",每个策略都包含状态化数据、特定事件和 actions。 这意味着我们只需要很少的代码就能实现特定场景下的请求。我记得第一次使用时,我惊讶地发现原来复杂的请求逻辑可以如此简洁!
如果你想了解更多关于 alovajs 的信息,强烈推荐你去官网看看: alova.js.org。相信我,你会发现一个全新的世界!
useForm 的神奇用法
现在,让我们一起深入了解 useForm 的具体用法。每次我使用这些功能时,都会感叹它的设计有多么巧妙。
基本用法
useForm 的基本用法非常简单,看看这段代码:
const {
loading: submiting,
form,
send: submit,
onSuccess,
onError,
onComplete
} = useForm(
formData => {
return formSubmit(formData);
},
{
initialForm: {
name: '',
cls: '1'
}
}
);
只需要这么几行代码,我们就能获得表单状态、数据、提交函数等所有需要的东西。 第一次看到这个时,我简直不敢相信自己的眼睛!
自动重置表单
还记得以前每次提交表单后都要手动重置吗?那种繁琐的感觉简直让人抓狂。但是 useForm 为我们提供了一个优雅的解决方案:
useForm(submitData, {
resetAfterSubmiting: true
});
设置这个参数为 true,表单就会在提交后自动重置。 当我发现这个功能时,我感觉自己省了好几年的寿命!
表单草稿
你有没有遇到过这种情况:正在填写一个长表单,突然被打断,等回来时发现数据全没了?那种沮丧的感觉我再清楚不过了。但是 useForm 的表单草稿功能彻底解决了这个问题:
useForm(submitData, {
store: true
});
开启这个功能后,即使刷新页面也能恢复表单数据。 我第一次使用这个功能时,简直感动得想哭!
多页面表单
对于那些需要分步骤填写的复杂表单,useForm 也有令人惊叹的解决方案:
// 组件A
const { form, send } = useForm(submitData, {
initialForm: { /*...*/ },
id: 'testForm'
});
// 组件B、C
const { form, send } = useForm(submitData, {
id: 'testForm'
});
通过设置相同的 id,我们可以在不同组件间共享表单数据。 这个功能让我在处理复杂表单时不再手忙脚乱,简直是多页面表单的福音!
条件筛选
useForm 还可以用于数据筛选,这个功能让我在开发搜索功能时如虎添翼:
const { send: searchData } = useForm(queryCity, {
initialForm: { cityName: '' },
immediate: true
});
设置 immediate 为 true,就能在初始化时就开始查询数据。 这对于需要立即显示结果的场景非常有用,大大提升了用户体验。
看完这些用法,你是不是也和我一样,被 useForm 的强大所折服?它不仅简化了我们的代码,还为我们考虑了各种常见的表单场景。使用 useForm,我感觉自己可以更专注于业务逻辑,而不是被繁琐的表单处理所困扰。
那么,你有没有在项目中遇到过类似的表单处理问题?useForm 是否解决了你的痛点?我真的很好奇你的想法和经验!如果你觉得这篇文章对你有帮助,别忘了点个赞哦!让我们一起探讨,一起进步!
来源:juejin.cn/post/7425193631583305780
老板:不是吧,这坨屎都给你优化好了,给你涨500工资!!
前言
最近负责了项目的一个大迭代,然后目前基本的功能都是实现了,也上了生产。但是呢,大佬们可以先看下面这张图,cpu占用率100%,真的卡了不得了哈哈哈,用户根本没有一点使用体验。还有就是首屏加载,我靠说实话,真的贼夸张,首屏加载要十来秒,打开控制台一看,一个js资源加载就要七八秒。本来呢,我在这个迭代中我应该是负责开发需求的那个底层苦力码农,而这种性能优化这种活应该是组长架构师来干的,我这种小菜鸡应该是拿个小本本偷偷记笔记的,但是组长离职跳槽了,哥们摇身一变变成了项目负责人哈哈哈了。所以就有了这篇文章,和大家分享记录一下,毕业几个月的菜鸡的性能优化思路和手段,也希望大佬们给指点一下。
先和大家说一下。这个页面主要有两个问题 卡顿 和 首屏加载,本来这篇文章是打算把我优化这两个问题的思路和方法都一起分享给大家的,但是我码完卡顿的思路和方法后发现写的有点多。所以这篇文章就只介绍我优化卡顿的思路和方法,首屏加载我会另外发一篇文章。
卡顿
这个页面卡顿呢,主要是由于这个表格的原因,很多人应该会想着表格为什么会卡顿啊,但是我这个表格是真的牛逼,大家可以看我这篇文章 “不是吧,刚毕业几个月的前端,就写这么复杂的表格??”,顺便给我装一下杯,这篇文章上了前端热榜第一(还是断层霸榜哦)(手动狗头)。
言归正传,为了一些盆友们不想看那篇文章,我给大家总结一下(最好看一下嘿嘿嘿),这个表格整体就是由三个表格合成为一个表格的,所以这个页面相当于有三个表格。因为它是一个整体的,所以我就需要去监听这个三个表格滚动事件去保证它表现为一个表格,其实就是保证他们滚动同步,以及信息栏浮层正确的计算位置,有点啰嗦了哈哈哈。
其实可以看到,很明显的卡顿。而且,这还是最普通的时候,为什么说普通呢,因为这个项目是金融方面的,所以这些数据都是需要实时更新的,我录制的这个动图是没有进行数据更新的。然后这个表格是一共是有四百来条数据,四百来条实时更新,这也就是为什么cpu占用率百分百的主要原因。再加之为了实现三个表格表现为一个表格,不得不给每一个表格都监听滚动事件,去改变剩下两个表格滚动条,然后改变滚动条也会触发滚动事件,也就是说滚动一下,触发了三个函数,啥意思呢,就比如说我本来只用执行1行代码,现在会执行3行代码(如果看不明白,去上面那边文章的demo跑一下就知道了)。所以,我们就可以知道主要的卡顿原因了。
卡顿原因
看到这盆友们应该知道为什么卡顿了,如果还不知道,那罚你再重新看一遍咯。其实真可以去看一下那篇文章,那篇文章很好的阐述了这个表格为什么会这么复杂。
卡顿原因:
- 大量数据需要实时更新
- 三个表格滚动事件让工作代码量变成了三倍
优化效果
不行,得先学资本家给大家画个饼,不然搞得我好像在诈骗一样,可以看下面这两张动态图,我只能说吃了二十盒德芙也没有这么丝滑。虽然滚轮滚动速度是有差别,可能会造成误差,但是这两区别也太大,丝滑了不止一点点,肉眼都可以看的出来。
优化前
优化后
在看数据实时更新的前后对比动图,优化前的动图可以看到,cpu占有率基本都是100%,偶尔会跳去99%。但是看优化后的图,虽然也会有飙到100的cpu占有率,但是只是某一个瞬间。这肯定就高下立判了,吾与城北徐公孰美,肯定是吾美啊!
优化前
优化后
优化思路与方法
如何呢,少侠?是不是还不错!
前面已经说过了两个原因导致卡顿,我们只要解决这两个原因自然就会好起来了,也不是解决,只能说是优化它,因为在网络,数据大量更新,以及用户频繁操作等等其他原因,还是会特别卡。
如何优化三个表格的滚动事件
对于这三个表格,核心是一次滚动事件会触发三次滚动函数,而且三个事件函数其实都是大差不差的,都是去改变其余两个表格的上下滚动高度或者左右滚动宽度,换句话说,这个滚动事件的主要目的其实就是获取当前这个表格滚动了多少。那我们偷换一下概念,原本的是滚动事件去改变其他两个表格的滚动高度,不如把他变成滚动了多少去改变其他两个表格的滚动高度。懵了吧,少年哈哈哈哈!看下修改后的代码你就能细评这句话了,代码是vue3写法,而且并不全,大家知道我在干嘛就行。
修改前的js代码
const leftO = document.querySelector("#left")
const middleO = document.querySelector("#middle")
const rightO = document.querySelector("#right")
leftO.addEventListener("scroll", (e) => {
const top = e.target.scrollTop
const left = e.target.scrollLeft
middleO.scrollTop = e.target.scrollTop
rightO.scrollTop = e.target.scrollTop
rightO.scrollLeft = left
},true)
middleO.addEventListener("scroll", (e) => {
const top = e.target.scrollTop
leftO.scrollTop = e.target.scrollTop
rightO.scrollTop = e.target.scrollTop
},true)
rightO.addEventListener("scroll", (e) => {
const left = e.target.scrollLeft
const top = e.target.scrollTop
leftO.scrollTop = e.target.scrollTop
middleO.scrollTop = e.target.scrollTop
leftO.scrollLeft = left
},true)
修改后的js代码
const leftO = document.querySelector("#left")
const middleO = document.querySelector("#middle")
const rightO = document.querySelector("#right")
const top = ref(0)
const left = ref(0)
// 这个是判断哪个表格进行滚动了
const flag = ref("")
leftO.addEventListener("scroll", (e) => {
// 记录top和left
top.value = e.target.scrollTop
left.value = e.target.scrollLeft
flag.value = 'left'
}, true)
middleO.addEventListener("scroll", (e) => {
// 记录top
top.value = e.target.scrollTop
flag.value = 'middle'
}, true)
rightO.addEventListener("scroll", (e) => {
// 记录top和left
top.value = e.target.scrollTop
left.value = e.target.scrollLeft
flag.value = 'right'
}, true)
// 监听top去进行滚动
watch(() => top.value, (newV) => {
// 当前滚动就不进行设置滚动条了
flag.value!=="left" && (leftO.scrollTop = newV)
flag.value!=="middle" && (middleO.scrollTop = newV)
flag.value!=="right" && (rightO.scrollTop = newV)
})
// 监听left去进行滚动
watch(() => left.value, (newV) => {
// 当前滚动就不进行设置滚动条了
flag.value!=="left" && (leftO.scrollleft = newV)
flag.value!=="right" && (rightO.scrollleft= newV)
})
看完了吧,我简单的总结下我都干了啥,其实就是将三个滚动事件所造成的影响全部作用于变量,再通过watch
去监听变量是否变化再去作用于表格,而不是直接作用于表格。换句来说,从之前的监听三个滚动事件去滚动表格变成监听一个滚动高度变量去滚动表格,自然代码工作量从原来的三倍变回了原来的一倍。其实和发布订阅是有异曲同工之妙,三个发布者通知一个订阅者
。如此简单的一个事,为啥我要啰里吧嗦逼逼这么多,其实就是想让大家体会待入一下那种恍然大悟妙不可言的高潮感,而不是坐享其成的麻痹感。
如何优化大量数据实时更新
前面说过这是一个金融项目的页面,所以他是需要实时更新的。但是这个表格大概有四百来条数据,一条数据有二十一列,也就是可能会有八千多个数据需要更新。这肯定导致页面很卡,甚至是页面崩溃。那咋办呢,俗话说的好啊,只要思想不滑坡,办法总比困难多!
我们不妨想一想,四百来条数据都要实时更新吗?对,这并不需要!我们只要实现了类似于图片懒加载的效果,啥意思呢?就是比如当前我们屏幕只能看到二十条数据,我们只要实时更新的当前这二十条就行了,在滚动的时候屏幕又显示了另外二十条,我们在实时更新这二十条数据。不就洒洒水的优化了好几倍的性能吗。
我先和大家先说一下,我这边实现这个实时更新是通过websocket去实现的,前端将需要实时更新的数据id代码,发送给服务端,服务端就会一直推送相关的更新数据。然后我接下来就用subscribe代表去给通知服务端需要更新哪些数据id,unsubscribe代表去通知服务的不用继续更新数据,来给大家讲一下整体一个思路。
首先,我们需要去维护好一个数组,什么数组呢。就是在可视窗口的所有数据的id数组,有了这个数组我们就可以写出下面的一个逻辑,只要是在可视窗口的数据id数组发生了变化,就把之前的数据推送取消,在重新开启当前这二十条的数据推送
。
// idArr为当前在可视窗口数据id数组
function updateSubscribe(idArr){
// 取消之前二十条的数据推送
unsubscribe()
// 开启当前这二十条的数据推送
subscribe(idArr)
}
所以,现在问题就变成如何维护好这个数组了!这个是在用户滚动
的时候会发生变化,所以我们还是要监听滚动事件,虽然我们之前已经做了上面的表格滚动优化操作,我这边还是给大家用滚动事件去演示demo。言归正传,我们要获取到这个数组,就要知道有哪些数据的dom是在可视窗口中的!这里我的方法还是比较笨的,我感觉应该是有更好的方法去获取的。大家可以复制下面这个demo跑一下,打开控制台看一下打印的数组。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
padding: 0;
margin: 0;
}
.box {
width: 400px;
height: 600px;
margin: 0 auto;
margin-top: 150px;
border: 1px solid red;
overflow-y: scroll;
overflow-x: hidden;
}
.item {
width: 400px;
height: 100px;
/* background-color: beige; */
border: 1px solid rgb(42, 165, 42);
text-align: center;
}
</style>
</head>
<body>
<div class="box" id="box">
<div class="item" id="1">
1
</div>
<div class="item" id="2">
2
</div>
<div class="item" id="3">
3
</div>
<div class="item" id="4">
4
</div>
<div class="item" id="5">
5
</div>
<div class="item" id="6">
6
</div>
<div class="item" id="7">
7
</div>
<div class="item" id="8">
8
</div>
<div class="item" id="9">
9
</div>
<div class="item" id="10">
10
</div>
<div class="item" id="11">
11
</div>
<div class="item" id="12">
12
</div>
<div class="item" id="13">
13
</div>
<div class="item" id="14">
14
</div>
<div class="item" id="15">
15
</div>
<div class="item" id="16">
16
</div>
<div class="item" id="17">
17
</div>
</div>
</body>
<script>
const oBOX = document.querySelector("#box")
oBOX.addEventListener('scroll', () => {
console.log(findIDArr())
})
const findIDArr = () => {
const domList = document.querySelectorAll(".item")
// 过滤在视口的dom
const visibleDom = Array.prototype.filter.call(domList, dom => isVisible(dom))
const idArr = Array.prototype.map.call(visibleDom, (dom) => dom.id)
return idArr
}
// 是否在可视区域内
const isVisible = element => {
const bounding = element.getBoundingClientRect()
// 判断元素是否在可见视口中
const isVisible =
bounding.top >= 0 && bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight)
return isVisible
}
</script>
</html>
这段代码其实还是很好理解的,我就给大家提两个地方比较难搞的地方。
id的获取方式
我们这里是先在每个div手动的绑定了id,然后在通过是拿到dom的实例对象,进而去获取到它的id。而在我们实际的开发工作中,基本都是使用组件的,然后是数据驱动视图的。就比如el-table,给他绑定好一个数据列表,就可以渲染出一个列表。也就是说,这一行的dom和这一行绑定的数据是两个东西,我们所获取的dom不一定就能拿到id,所以怎么获取到每一行的id也是一个问题,反正核心就是将dom和数据id联系起来,这就需要大家具体问题具体分析解决了。
如何判断是否在可视区域
判断是否在可视区域主要是通过getBoundingClientRect
函数,这个函数是可以获取一个元素的六个属性,分别是上面(下面)的这几个属性,然后就可以根据这些字段去判断是否在可视区域。
- width: 元素的宽度
- height: 元素的高度
- x: 元素左上角相对于视口的横坐标
- y: 元素左上角相对于视口的纵坐标
- top: 元素顶部距离窗口的距离
- left: 元素左侧距离窗口左侧的距离
- bottom: 元素底部距离窗口顶部的距离 (等于 y + height)
- right: 元素右侧距离窗口右侧的距离(等于 x + width)
进一步优化
除了上面这些,我还做一个优化,啥优化呢?就是在vue中因为是响应式驱动,只要数据一发生变化就会触发视图更新,但是如果变化的太频繁,也会特别卡,所以我就添加了一个节流,让他一秒更新一次,但是这个优化其实是有一丢丢问题的。为什么呢,比如以一秒为一个时间跨度,他本来是在0.5秒更新的,但是我现在把他变成了在1秒更新,在某种意义上他就并不实时了。但是做了这个操作,性能肯定是比之前好得多,这就涉及到一个平衡了,毕竟鱼和熊掌不可兼得嘛。因为保密协议巴拉巴拉的,我就给大家写了个伪代码。
// 表格绑定的值
const tableData = ref([])
// 表格原始值
const tableRow = toRaw(tableData.value)
// 定时器变量
let timer
// 更新函数
const updateData = (resData) => {
// resData是websocket服务端推送的一个数据更新的数组,我们假设resData这个数据结构是下面这样
// [{
// id: "",
// data: {}
// },
// {
// id: "",
// data: {}
// }]
resData.forEach(item => {
// 更新的id
const Id = item.id
// 先去找tableRow原始值中找到对应的数据
const dataItem = tableRow.findIndex(row => row.id == Id)
// 更新tableRow原始值数据
dataItem[data] = item.data
})
if(!timer){
timer = setTimeout(()=>{
// 这个时候才去更新tableData再去更新视图
tableData.value = [...tableRow]
timer = null
},1000)
}
}
我大概的讲一下这段代码在干嘛。假设我这个表格绑定的值是tableData
,我用vue3的toRaw
方法,将这个拷贝一份形成一个没有响应式的值为tableRow
。这里提一嘴,toRaw
这个方法并不是深拷贝,他只是丧失了响应式了,改变tableRow
的值,tableData
也会发生变化但是不会更新视图。updateData
大家可以看成封装好的更新方法。传入的参数为服务端推送的数据,它是一个全是对象的数组。这段代码的核心就是服务端推送的数据先去更新tableRow的值,再利用节流实现一秒更新一次tableData的值。
toRaW
这里再给大家分享一个知识,大家可以看到我去更新的tableData
的值的时候是新创建了一个数组,然后用...扩展运算符
去浅拷贝。这是因为如果直接用toRaw后的对象去赋值给响应式的的对象,这个对象也会丧失响应式。但是如果只是某一个属性单独赋值是不会丧失响应式的
单独属性赋值
import { reactive, toRaw } from 'vue';
const state = reactive({ count: 0 });
const rawState = toRaw(state);
// 将原始对象的属性值赋给响应式对象的属性
state.count = rawState.count;
const increment = () => {
state.count++;
};
increment();
console.log(state.count); // 响应式更新,输出1
整个对象赋值
import { reactive, toRaw } from 'vue';
const state = reactive({ count: 0 });
const rawState = toRaw(state);
// 错误地用原始对象替换响应式对象
state = rawState;
// 这会导致错误,因为不能重新赋值响应式对象本身,并且响应式关联被破坏
并不是深拷贝
import { reactive, toRaw } from 'vue';
const nestedObj = reactive({
a: 1,
b: {
c: 2
}
});
const rawObj = toRaw(nestedObj);
// 修改原始对象的属性
rawObj.a = 10;
console.log(nestedObj.a); // 输出10,说明不是深拷贝,因为修改原始对象影响了响应式对象
总结
其实整体来看,并没有做一些高大上的操作,但是性能确实好了很多很多。去年面试的时候被问到性能优化总是会很慌张,因为我一直觉得的性能优化特别牛逼,我也确实没有做过什么性能优化的操作,只能背一些八股文,什么防抖节流,图片懒加载,虚拟列表......然后我想表达啥呢,因为我觉得肯定很多人面试的时候很怕被问到性能优化,特别是现在在准备秋招春招啥的,因为我也刚毕业三四个月,我包有体会的。所以我想告诉大家的意思的,性能优化并没有这么高端,只要是能让你的项目变好的,都是性能优化。实在不行,你就好好看哥们的写的东西,你就说这个表格是你写,反正面试不就是糊弄面试官的吗,自信!
来源:juejin.cn/post/7430026536215281698
6个月时间,我放弃一人企业又去打工了
本来没有心情写的,但是跟读者承诺过三个月后再汇报……
因为被欠薪,在IT圈子规规矩矩上班12年,年龄34岁的我,决定先不找工作。今年4月份打算在家里自己搞些事情,并且还发布了一篇《IT男的一人企业》以明志。随后,“TF男孩”修改昵称为“IT男的一人企业”,斗志昂扬地高喊自由之歌:打工一眼看到头,创业灵活又自由……
7月份,过去三个月了,我写了一篇总结《那个不找工作在家干一人企业的程序员,现在怎么样了?》,汇报了我的收入和近况。其实那三个月,已经是巅峰。对于我来说,一个月挣1万多,已经是到顶了。
到8月份时,收入减至一千,9月时,低至几百。让我放弃的主要原因没有别的,就是收入。本身我的太太不支持我辞职,即便我有很多理由,比如这个行业可能是青春饭,要早考虑以后,早死早投胎,等人家失业,我已经探索出新路子了。而她的想法却是既然是青春饭,那就趁着青春多吃几年,等到吃不动再探索。
为了实现自己的想法,我还专门回老家,问父母要了些钱,这些钱等同原来两个月的工资。这样,我才有了底气去放心做一人企业。
后来的事情,如上文总结里所说,其实还可以。凭借我多年写文章的影响力,我主打一个廉价可行AI技术方案。就是写很多小老板想实现的,但是他们问自己的技术部,技术部说不可能实现。一般小企业技术能力一般,另外也懒惰。而这事在我这里成本只需要三五千。我一般会推荐他们看我的教程文章,如果抄作业他们都懒得抄。老板们就会找我做。成本低,他们也愿意试错,因为亏不了多少。而如果成了,他们的业务可能会插上飞翔的翅膀。我提供的AI方案,也确是可行,是自己验证过的适合小企业的低成本(尽量CPU)、开源、可商用、可本地化部署的项目。
8月、9月两个月孩子放暑假,我是专职带娃。没想到,带娃后根本没时间。我买了两个沙漏用于时间可视化。我跟孩子约定,她自己玩一个沙漏时间,然后我陪她一个沙漏的时间,类似于番茄工作法。这样,我就有些时间做自己想做的。但是,孩子虽然表面答应,她还是跟你捣乱。只能孩子睡觉时做些事情。
也是这个时候,家庭矛盾开始激化。主要还是没钱。但是却体现在我地拖得不干净,饭做得不好吃,为什么孩子又在看动画片……没办法,受不了鸡飞狗跳,提高厨艺根本没用,获取持续稳定的收入,是解决一切问题的关键。
9月中期开始找工作,很快就找到了。现在找AI算法岗位工作并不难。但凡招人的企业,就算是三五个人的小公司,它也要招一个AI工程师,为的就是盘活老业务,讲好智能故事。
我入职的是一个500多人的传统企业,成立20多年了,这里IT技术十来人。除了上线前后,不加班,当然工资也不高。
另外,上家公司的欠薪至今也没有追回来。去年9月开始欠薪,今年年初仲裁判决公司支付我拖欠的工资。然后公司不服仲裁判决,向法院起诉,我已收到传票。看到公司的起诉状我也无语了,他请求法院判定不支付工资xx.1元,应支付工资是xx.2元。改了一个数,还给我涨了钱,继续走一审二审。一个周期又是半年起步。目前公司面临几百例强制执行和几十个限制高消费,都是劳动纠纷的,已经启动破产。而根源则是老板盲目扩张,欠薪几千万了,还未停止大规模招人。他总想着自己振臂一呼,大量资金会涌入。
从去年9月到今年9月,我已经一年没有工资收入了。这也是经济压力大的原因。换哪个普通家庭都受不了。这么看,打工是有风险的。但是,打工的风险可控。最多损失1~2个月工资。当然,这得是你足够机灵的情况下。上家公司,被欠八个月工资的也有,甚至有员工私人带款给公司花。里面的操作不细说。
不打工的风险我也试过。就是上面写的,可能持续没有收入,是消耗存量等待增量。
我以前觉得,对于个人的事业,可能只有拼进全力才能稍微有点效果。比如我要验证一件自己的想法,上班时可能需要半年。但是当我全职去做时,可能半个月就验证出来了。看得鸡汤多了些,说什么行业竞争很激烈,时间就是机会,人家专职团队都干不成,凭啥你业余时间搞就能成功。所以要全身心投入创业。
而我的老领导说,如果你有想法,上着班也能把一个事业干成。如果你没有想法,就算专职干也没啥用。有些事情是需要孵化周期的,还有些事情是需要等待的。长期策略更适合普通人。我感觉还是他的话比较温和与现实。
事情就是这么个事情。昵称又改回了“TF男孩”。TF是TensorFlow的简称,因为我学AI是从tensorflow开始。以后也没有啥一人企业了。我乐意变回那个普通的男孩。
来源:juejin.cn/post/7424915312166600755
离职后的这半年,我前所未有的觉得这世界是值得的
大家好,我是一名前端开发工程师,属于是没有赶上互联网红利,但赶上了房价飞涨时代的 95 后社畜。2024 年 3 月份我做了个决定,即使已经失业半年、负收入 10w+ 的如今的我,也毫不后悔的决定:辞职感受下这个世界。
为什么要辞职,一是因为各种社会、家庭层面的处境对个人身心的伤害已经达到了不可逆转的程度,传播互联网负面情绪的话我也不想多说了,经历过的朋友懂得都懂,总结来说就是,在当前处境和环境下,已经没有办法感受到任何的快乐了,只剩焦虑、压抑,只能自救;二是我觉得人这一辈子,怎么也得来一次难以忘怀、回忆起来能回甘的经历吧!然而在我的计划中,不辞职的话,做不到。
3 月
在 3 月份,我去考了个摩托车驾-照,考完后购买了一辆摩托车 DL250,便宜质量也好,开始着手准备摩旅。
4 月份正式离职后,我的初步计划是先在杭州的周边上路骑骑练下车技,直接跑长途还是很危险的,这在我后面真的去摩旅时候感受颇深,差点交代了。
4 月
4.19 号我正式离职,在杭州的出租屋里狠狠地休息了一个星期,每天睡到自然醒,无聊了就打打游戏,或者骑着摩托车去周边玩,真的非常非常舒服。
不过在五一之前,我家里人打电话跟我说我母亲生病了,糖尿病引发的炎症,比较严重,花了 2w+ 住院费,也是从这个时候才知道我父母都没有交医保(更别说社保),他们也没有正式、稳定的工作,也没有一分钱存款,于是我立马打电话给老家的亲戚让一个表姐帮忙去交了农村医保。所有这些都是我一个人扛,还有个亲哥时不时问我借钱。
说实话,我不是很理解我的父母为什么在外打工那么多年,一分钱都存不下来的,因为我从小比较懂事,没让他们操过什么心,也没花过什么大钱。虽然从农村出来不是很容易,但和周围的相同条件的亲戚对比,我只能理解为我父母真的爱玩,没有存钱的概念。
我可能也继承了他们的基因吧?才敢这样任性的离职。过去几年努力地想去改变这个处境,发现根本没用,还把自己搞得心力交瘁,现在想想不如让自己活开心些吧。
5 月
母亲出院后,我回到杭州和摩友去骑了千岛湖,还有周边的一些山啊路啊,累计差不多跑了 2000 多公里,于是我开始确立我的摩旅计划,路线是杭州-海南岛-云南-成都-拉萨,后面实际跑的时候,因为云南之前去过,时间又太赶,就没去云南了。
6 月
在摩友的帮助下,给摩托车简单进行了一些改装,主要加了大容量的三箱和防雨的驮包,也配备了一些路上需要的药品、装备,就一个人出发了。
从杭州到海南这部分旅行,我也是简单记录了一下,视频我上传了 B 站,有兴趣的朋友可以看看:
拯救焦虑的29岁,考摩托车驾-照,裸辞,买车,向着自由,出发。
摩托车确实是危险的,毕竟肉包铁,即使大部分情况我已经开的很慢,但是仍然会遇到下大雨路滑、小汽车别我、大货车擦肩而过这种危险情况,有一次在过福建的某个隧道时,那时候下着大雨,刚进隧道口就轮胎打滑,对向来车是连续的大货车,打滑之后摩托车不受控制,径直朝向对向车道冲过去,那两秒钟其实我觉得已经完蛋了,倒是没有影视剧中的人生画面闪回,但是真的会在那个瞬间非常绝望,还好我的手还是强行在对龙头进行扳正,奇迹般地扳回来且稳定住了。
过了隧道惊魂未定,找了个路边小店蹲在地上大口喘气,雨水打湿了全身加上心情无法平复,我全身都是抖的,眼泪也止不住流,不是害怕,是那种久违地从人类身体发出的求生本能让我控制不住情绪的肆意发泄。
在国道开久了人也会变得很麻木,因为没什么风景,路况也是好的坏的各式各样,我现在回看自己的记录视频,有的雨天我既然能在窄路开到 100+ 码,真的很吓人,一旦摔车就是与世长辞了。
不过路上的一切不好的遭遇,在克服之后,都会被给予惊喜,到达海南岛之后,我第一次感觉到什么叫精神自由,沿着海边骑行吹着自由的风,到达一个好看的地方就停车喝水观景,玩沙子,没有工作的烦扰,没有任何让自己感受到压力的事情,就像回到了小时候无忧无虑玩泥巴的日子,非常惬意。
在完成海南环岛之后,我随即就赶往成都,与前公司被裁的前同事碰面了。我们在成都玩了三天左右,主要去看了一直想看的大熊猫🐼!
之后我们在 6.15 号开始从成都的 318 起始点出发,那一天的心情很激动,感觉自己终于要做一件不太一样的事,见不一样的风景了。
小时候在农村,读书后在小镇,大学又没什么经济能力去旅行,见识到的事物都非常有限,但是这一切遗憾在川藏线上彻底被弥补了。从开始进入高原地貌,一路上的风景真的美到我哭!很多时候我头盔下面都是情不自禁地笑着的,发自内心的那种笑,那种快乐的感觉,我已经很久很久很久没有了。
同样地,这段经历我也以视频的方式记录了下来,有兴趣的朋友可以观看:
以前只敢想想,现在勇敢向前踏出了一步,暂时放下了工作,用摩托跑完了318
到拉萨了!
花了 150 大洋买的奖牌,当做证明也顺便做慈善了:)
后面到拉萨之后我和朋友分开了,他去自驾新疆,我转头走 109 国道,也就是青藏线,这条线真的巨壮美,独自一人行驶在这条路,会感觉和自然融合在了一起,一切都很飘渺,感觉自己特别渺小。不过这条线路因为冻土层和大货车非常非常多的原因,路已经凹凸不平了,许多炮弹坑,稍微骑快点就会飞起来。
这条线还会经过青海湖,我发誓青海湖真的是我看到过最震撼的景色了,绿色和蓝色的完美融合,真的非常非常美,以后还要再去!
拍到了自己的人生照片:
经历了接近一个半月的在外漂泊,我到了西宁,感觉有点累了,我就找了个顺丰把摩托车拖运了,我自己就坐飞机回家了。
这一段经历对我来说非常宝贵,遇到的有趣的人和事,遭遇的磨难,见到的美景我无法大篇幅细说,但是每次回想起这段记忆我都会由衷地感觉到快乐,感觉自己真的像个人一样活着。
这次旅行还给了我感知快乐和美的能力,回到家后,我看那些原来觉得并不怎么样的风景,现在觉得都很美,而且我很容易因为生活中的小确幸感到快乐,这种能力很重要。
7 月
回到家大概 7 月中旬。
这两个多月的经历,我的身体和心态都调整的不错了,但还不是很想找工作,感觉放下内心的很多执念后,生活还是很轻松的,就想着在家里好好陪陪母亲吧,上班那几年除了过年都没怎么回家。
在家里没什么事,但是后面工作的技能还是要继续学习的,之前工作经历是第一家公司用的 React 16,后面公司用的是 Vue3,对 React 有些生疏,我就完整地看了下 React 18 的文档,感觉变化也不是很大。
8、9 月
虽然放下了许多执念,对于社会评价(房子、结婚、孩子)也没有像之前一样过于在乎了,但还是要生活的,也要有一定积蓄应对未来风险,所以这段时间在准备面试,写简历、整理项目、看看技术知识点、刷刷 leetcode。
也上线了一个比较有意义的网站,写了一个让前端开发者更方便进行 TypeScript 类型体操的网站,名字是 TypeRoom 类型小屋,题源是基于 antfu 大佬的 type-challenges。
目前 Type Challenges 官方提供了三种刷题方式
- 通过 TypeScript Playground 方式,利用 TypeScript 官方在线环境来刷题。
- 克隆 type-challenges 项目到本地进行刷题。
- 安装 vscode 插件来刷题。
这几种方式其实都很方便,不过都在题目的可读性上有一定的不足,还对开发者有一定的工具负担、IDE 负担。
针对这个问题,也是建立 TypeRoom 的第一个主要原因之一,就是提供直接在浏览器端就能刷题的在线环境,并且从技术和布局设计上让题目描述和答题区域区分开来,更为直观和清晰。不需要额外再做任何事,打开一个网址即可直接开始刷题,并且你的答题记录会存储到云端。
欢迎大家来刷题,网址:typeroom.cn
因为个人维护,还有很多题目没翻译,很多题解没写,也还有很多功能没做,有兴趣一起参与的朋友可以联系我哦,让我一起造福社区!
同时也介绍下技术栈吧:
前端主要使用 Vue3 + Pinia + TypeScript,服务端一开始是 Koa2 的,后面用 Nest 重写了,所以现在服务端为 Nest + Mysql + TypeORM。
另外,作为期待了四年,每一个预告片都看好多遍的《黑神话·悟空》的铁粉,玩了四周目,白金了。
现在
现在是 10 月份了,准备开始投简历找工作了,目前元气满满,不急不躁,对工作没有排斥感了,甚至想想工作还蛮好的,可能是闲久了吧,哈哈哈,人就是贱~
最后
其实大多数我们活得很累,都是背负的东西太多了,而这些大多数其实并不一定要接受的,发挥主观能动性,让自己活得开心些最重要,加油啊,各位,感谢你看到这里,祝你快乐!
这是我的 github profile,上面有我的各种联系方式,想交个朋友的可以加我~❤️
来源:juejin.cn/post/7424902549256224804
HR的骚操作,真的是卧龙凤雏!
现在基本已经对通过面试找工作不抱啥希望了。
有时候面试官和我聊的,还没有前面hr小姐姐和我聊的多,我一听开场白就基本知道就是拿我走个过场,没戏!
现在的面试流程都是人事先和你聊半天,没什么硬伤大坑才会放你去见面试官。
二零一几年那会可不是这样,第一次的详聊都是直接业务层,业务的人觉得你ok,你再和人事沟通,定个薪资就完了。
13年的时候我在一家外企,三千的月薪。当时我一个小目标就是月薪过五千。
可别笑,13年的月薪五千,那还是能勉强算上一个小白领的。
我就老琢磨着升职加薪。但眼下的公司规模小,人员基本不扩增,不流通,我就想跳槽了。
当时我同时面了AB两家外资游戏公司。都过了业务层的面试,只剩和人事定薪资。
我给A公司报价5500,给B公司报价6000,因为我知道B公司刚来国内开拓业务,属于扩张期。
这时候,A公司HR的骚操作就来了,她说:“嗯,5500嘛,有难度,但不是不可能,我可以帮你跟老板争取。”
然后又问我:“你已经从现在的公司里面离职了吗?”
我说:“还没呢,我想先把下家定了。”
她就说:“哎呀,那有点难办,你得先从现在这家公司离职,我得确保我帮你争取下来后,你不会鸽我,不然我没法和老板交代,要不你先把那边离职办了吧。”
我说:“那我再考虑考虑吧。”
然后没过两天,我收到了B公司的offer。人家都没还价,直接定了6000,我就开始走离职手续。
这时候A公司的HR又出来问我,你从现在的公司离职了吗?
我说离了,她说你给我看看离职证明,我就拍照给她看离职证明。
然后她连假装让我等一会儿,说自己去问一下老板的戏都不演了,直接秒回说:“我帮你问了老板了,老板说不行,5500给不了,最多给到4500,要不你先入职呢,后面有机会提加薪的。”
瞬间给我恶心的,怎么会有这么恶心的人事!先把你忽悠离职,然后翻脸不认人,可劲往下砍价,为了公司的KPI,自己做人的脸都不要了。
我当时就觉得这样的人真傻,就算我认了4500的杀价入了职,我把和她的对话记录公司群里一发,老板会怎么看她,同事会怎么看她。
咱做人得有底线呀,你用这种脏办法帮公司省那几百块钱,还把自己的名声信誉搭进去了,真的值得吗?
后来我在入职B公司差不多半年后,传来了A公司解散倒闭的消息,我心里还暗爽了一把,幸亏当年没进那个火坑。
但半年后,我所在的B公司也解散了。
2013年那是一个手游刚兴起的疯狂年代,数不清的大小公司起家,创业,失败,解散,换批核心班子,再起家,再失败,浮浮沉沉,我也成了疯狂年代下的沧海一粟。
来源:juejin.cn/post/7426685644230213643
37 岁程序员被裁日记
37 岁被裁员,老婆即将临盆,求职却毫无音讯,我经历了人生中最艰难的时刻。我将这段时间每天发生的故事写进了日记,既是对未来生活的警醒,也希望能给面临相似困境的同伴们带来一些启示——无论多么艰难,绝不能放弃希望。
2024.8.27 (背景)
我在 2021 年九月底入职了某外企(以下简称 CP),合同三年。入职后,才知道这家企业有两个“潜规则”:
- Senior 岗(我就是这个岗)如果三年内不能晋升,原则上就滚蛋
- 每年大约有 10% ~ 15%的 PIP(绩效优化)比率
我躲过了三年的 PIP,但是没机会晋升。按惯例,三年合同到期前,公司会提前一个月通知我不再续约。只是这个是潜规则,所以我一直心存侥幸,盼着已经是 8 月底了,如果没有通知我,就可以躲过这一难。
但是——真的是卡着点——今天我还是被老板通知不续约了,他的话很平静:“有一个对你不是很好的消息要告诉你,director 想换一个更好的前端……”。
其实从去年年底开始我就意识到了不对劲,但是一直心存幻想,想在最后的 promotion 的机会里再拼一次;即便没有晋升成功,也想让老板感觉到我有点价值——至少能保住饭碗。然后上半年一个人徒手搭建了新的 web 技术栈,一举减少了大几十天的人力成本;部门上线新项目,45%工作是前端相关,我带着四五个不大会写 react 的后端同事紧赶慢赶完成了交付;这期间我参加了公司的 hackathon 项目,还获得了优胜;另外还在部门里分享里数次 Lightning talks,还有两个专利的输出。总以为这些努力至少不会让老板 nominate 我的时候太觉难堪吧。可是,nomination 还是与我无关,他的回复是“你没达到我的 bar,我是不会 nominate 你的;你如果觉得有续约风险,你应该一早让 director 来 nominate 你”。冷冰冰的,没有一丝三年共事的情分。确实是我太幼稚了,我以为我和老板的关系是:我给你干活,你帮我实现职业目标;但是这个老板的想法是,我是公司派来监督你的人,你不合格,我就替公司换人。
后来我提前问他续约的事,他信誓旦旦地说“现在招个 L6-2 的前端不容易(公司政策只招比我级别高的人),我会同意让你续约的”,我又问他这个谁说了算,他说他说了算。但是我一点都不信。过了一个月我们部门 L6-1 的前端 HC 赫然在目(L6-1 需要 VP 特批才能要到 HC);但是他依旧装着什么都不说,非得等到 8 月底最后一次 1 on 1 的机会(每两周我和他有一次 1 on 1),他才告诉我:“有一个对你不是很好的消息要告诉你,director 想换一个更好的前端……”
他一直知道我的困难: 外面行情很差、我年纪也不小了、老婆即将临盆。但是最后的谈话,至始至终都在甩锅给上级;切记切记,不要相信老板的说辞,不要对公司抱有幻想,更不要心存侥幸。和他的谈话结束后,我就与几个要好的同事做了简短的告别;之后一个月基本不会去厂里了,毕竟已经没有意义了。
2024.8.28
早上还是有一些同事找我处理业务上的事,我私信回了它们,不再参与代码了。他们虽然有点震惊,最后也给了我祝福。一些同事很讲义气的,给我推了 HR 和猎头。
- 早上,我联系了一个猎头,告诉我蚂蚁国际有 HC,但是我还没准备中文版的简历,也没开始背八股,所以暂时没投递。
- 中午,我联系了途虎的 HR,我直言不讳地问了是否卡年龄。我感觉她犹豫了,37 岁却是太大了。
- 下午,我和 AWX 的前同事喝了杯咖啡。他是这里的后端开发,帮我询问了前端的面试流程。听说 leetcode 难度可能到 hard;而且很难躲过我最讨厌的八股文。对于项目面,他的经验是去别的厂里多历练历练,能把项目吹得滴水不漏才好。
然后回家的时候,CP HR 通知我可以讨论赔偿的事宜了。真的是太感人了:昨天老板通知,今天 HR 就来了,太急了吧。我又联系了之前被赶走的同事,他告诉我公司还是很抠的:是个小 n,不是大 N。哎……
2024.8.29
今天早上陪老婆去做了产检,孩子的预产期是十月初,而我的工作合同将在 9 月 29 日正式结束。希望宝宝能带来好运,出生时爸爸已经为他准备好了奶粉钱。顺带一提,我推迟了和法务部门第二天的会面,关于赔偿的问题暂时不急,还是找些时间专心学习吧。
午饭后,我更新了一版简历并发送给了途虎的 HR,但遗憾的是一天都没有收到回复。下午,隔壁邻居开始装修,噪音让我无法静心学习。于是,我选择去了社区图书馆,这里不仅可以免费看书,还有空调,虽然没有网络,但说实话,是一个避暑的好去处。
图书馆里主要是一些做作业的学生以及一位白发苍苍的老奶奶,她用放大镜整整看了一下午的报纸。而我,也趁机读完了一整本《图解 HTTP》。不过,为了确保面试顺利,还得专门背诵一些关于 HTTP 的面试题。
2024.8.30
早上,我给蚂蚁国际的猎头发了简历,时间不等人,无论如何我也得硬着头皮上了。她给了我几道历年真题,但都是大路货,没什么用。
下午的时候,base 在韩国的一个同事来问我现状,推荐我可以看看 Booking 和 AWX。Booking 我知道已经没有 HC 了,AWX 还想再准备一下。虽然没什么帮助,但是哥们这么老远还特地来关心我,还是很讲义气的,感动……
2024.8.31
今天礼拜六,对于找工作的人来说,没有双休日。我又去了社区图书馆,这里每天都开放。道听途说某些公司的前端可能会有 UI 设计面,我看完了《用户体验要素》。不过,囫囵吞枣,应该对面试起不了太大作用。说实在这个阶段最难的是:不知道自己缺什么;感觉什么都要准备,但是什么都不够。
老婆比我更加焦虑了,大着肚子,看她也没睡好。然后催着我快点给 AWX 投简历。说实在,它们家是现在我能发现难得有我这种 HC 的外企了,第一面就是它们家很担心凉凉了。
2024.9.01
今天又是学习的一天。早上第一件事是背六级单词;每天 20 个单词,累计打卡第 254 天。在 CP 这些年,最大的收获还是上英语课——Speexx。两次半年的口语培训,让我有了很大的成长;虽然和 Native English speaker 相比还是有本质差距的,但是对国内有英语要求的公司,这些年我不再那么心虚了。
中午时分,老婆在刷 boss 直聘的时候,看到 Paypal 放出了 HC。虽然这样的公司一次可能就放一两个 HC 吧,但还是让我阴霾的心闪现出一丝光亮:“金九银十”要来了?我马上托同事帮我打听内部情况——他老婆正好在 paypal 当前端,应该周一才能知道详情。他又提醒我可以海投国内大厂,比如美团、字节、腾讯什么的,不要总是想着小而美的外企。他去过很多公司,身经百战;而我九年就跳过一次槽,心中还有”面试恐惧症“。他的话一下把我点醒了——我现在的工作就是“面试”,没什么可患得患失了。
2024.9.02
早上本来要陪老婆去产检的,但是被她厉声喝止了:让我专心在家改简历——把 AWX 的简历交了。这种糟糕的心态,不可避免地传递到了老婆身上了。于是我又改了版简历,给 AWX 的师兄交了过去;他帮我指出了几处拼写错误——幸好师兄心善,真的是太丢脸了。不到一个小时,HR 就加我微信了。我想缓缓,就把 HR 的电话面约到了第二天。(真没出息,竟然三年没面过试,HR 面都紧张成这样了)
下午的时候,又一个同事来找我;他也快到期了。从我这里得到的消息,让他心里瞬间凉了一截。看样子大家都是差不多的情况:到了这个岁数,能不能延续职业生涯,是绝大多数人的心头病了。
2024.9.03
早上十点和 AWX 的 HR 视频了半小时。他们家没有 behavioral 面,就是随便介绍一下自己的期望薪资,以及面试流程。
- 一面 coding:leetcode,手撸 promise,实现 UI 组件都有可能(跟没说一样)
- 二面设计面:应该不是市面上正统的系统设计面
- 三面 Line manager:聊项目
- 四面 VP:看他心情,可能是人生面吧
其实没啥有价值的信息,并没有缩小我的准备范围。我还特地花了 79 块钱开通了 leetcode 会员,就是为了看看真题;结果就五题,亏大了。
吃完晚饭,四个同事几乎同时向问我打听现状,并表示有需要可以帮助内推他们之前的公司。感觉他们应该是有个小群正好说到我的事情吧。虽然暂时帮不到什么忙,但还是很感激的。说实在,我们的老板工作中已经有点去人格化了,但是他手下的人还都是温情默默的。
2024.9.04
今天,新前端正式加入公司。本来预计他是两周后才上班;可能真的是缺人手吧,催着他提前入职了。爽文小说里“裁员裁到大动脉”的剧情现实中说很难发生;我离开的一个礼拜里,部门里也仅仅发生了一个 minor 的 incident。可能对于老板来说,他又一次成功地实现了“以旧换新”。至于同行,也没有恨意,祝愿他在新公司一切顺利;并期望自己也能成功找到下一份工作。至于不开心的事,就让它随风而去吧;收下伤感,继续背八股了。
2024.9.05
今天终于参加了第一次面试——zoom 面,这两天刷了十几道 leetcode 题。但是面试的时候,前五十分钟在聊项目,最后十分才做了到算法题;跟预期差距也太大了。说实在聊项目的细节我还有所欠缺,本想 coding 和设计面后再恶补一下,没想到第一面就来了。复盘一下我自己的缺陷:
- 讲项目的时候,应该分享个画板,给面试官更好的体验
- 微前端这块写到了简历里,但是八股知识不够扎实
- UI 这块,design system 得预习一下
当然,上述缺陷的本质问题是面试经历太少了,还没把自己的各个方面都武装过一遍。算了,放平心态,即便是挂了,至少也攒到了些经验。
2024.9.06
一夜无眠,还在想昨天的那道算法题。虽然不难,但是我漏掉了负数的判断,不知道面试官有没有注意到。这种心态真的很糟糕,一直提醒自己:这仅仅是一次面试而已,不能影响后面的准备。早上十点,我主动问了 HR 一面结果;等了一会儿,她回答我可以准备二面了。一下子心态平了很多。
之后我看了会儿阮一峰的博客,他的博客有个《谁在招人》的板块,但很可惜没有什么理想的公司。不过,他介绍了一个神奇网站,叫轻松游牧:一个远程工作聚合网站,每天从网上收集支持国内远程的岗位。我听说有一种面向国外的远程外包,也是通过类似网站招人的;每年给个固定的包工费,但是价位会比国内的外包高很多。再过段时间如果还是找不到工作的话,得试试这个模式了。
2024.9.07
礼拜六继续图书馆走起,坐在我傍边的依旧是同一帮爷爷奶奶们——看年纪他们应该都有八十好几了;他们真的是全年无休,人手一个放大镜,一直沉静在自己的书海里。上海确实是一个不一般的地方,这种人文的气质很是让我这种“乡下人”鼓舞的。下午看完了《微前端实战》——豆瓣 8.8 分,应该是评分太少,有点虚高了。不过,读完还是让我开阔了许多眼界,它顺便还提到了点 design system,与我上一次面试所欠缺的知识点不谋而合;希望下一轮面试能帮到我一点吧。回忆起这三年,我还是买了了挺多书的(公司每季 200 元图书福利),但可惜真正读完的没几本;现在临走了,却逼着我看了好几本,真是有点好笑的。
2024.9.08
今日 leetcode:977,1589(之后把每天 leetcode 题也记一下)
早上我一般都是先背英语单词,然后两道 leetcode。今天,我特意试了一下 leetcode 里 Paypal 原题:超过 1/4 是 hard 题;即便是 medium 题,也是包着算法外衣的数学题。很好奇,这个公司真的这么卷算法吗?
下午主要在看设计题,说实在前端系统设计很小众,网上的资料也就《News Feed》和《autocomplete》两题。我三年前进 CP 厂的时候面过一次设计题——《前端的 web log》,那时答得稀巴烂。这次比之前多看了些资料,只能说稍微了解了些套路;但从来没有实战过,说实在比之 coding 面更加没有信心。
2024.9.09
今日 leetcode:2181, 1969
不知道大家在网上是怎么找面经的,我之前主要在看准网找留言。但是今天登录看准网后,出了个弹窗,意思大致是它们家要关门了。哎,真是让人唏嘘不已;猜测写这个弹窗的前端小哥应该也已经被裁了吧。后来,我又去看了一亩三分地。这里的面经谈到这家设计题主要是 OOD 的题目,和传统的 system design 不同。所以白天我一直在油管上看某些高频题目的设计视频。但心里也明白,这些都是盲猜;很可能是浪费一天宝贵时间。说实在,面试也是信息检索的一种考验,要是又靠谱的信息渠道,面试成功率就能高很多了。
2024.9.10
今日 leetcode:2554、2559
下午 2 点二面。果然每次面试内容和预期的都不一样。所谓的设计面,根本和设计无关;事实上是聊项目+coding。项目面,前几天看的《微前端实战》倒是给了我点解答问题的灵感;但是 coding 表现很不好,时间还剩下十几分钟,我怕来不及,竟然选择纯口嗨——给面试官口头解释如何实现。有点后悔了,面试结束我自己又试了一下,其实这道题对我来说时间是充裕的。哎,还是太紧张了,策略错误……所幸,一个小时后,HR 邀我两天后三面;谢谢面试官网开一面。
2024.9.11
今日 leetcode:1999、2380
今天主要还是集中精力准备第二天的面试,虽然被告知是项目面,但是我心里还是没底——之前的两面都是 surprise。不过事已至此,我也只能全力总结项目了(再让我背八股,我也来不及了)。二面的经验让我想到了一个面试策略:就是事先把项目的架构图画好,面试的时候直接对着架构图讲业务;有点类似于 UI 设计人员在面试时会展示自己作品集的那个意思。我面经不足,不知道大家是不是都这么干的。
2024.9.12
今日 leetcode:2524、2576
上午 11 点 Line Manager 面,他让我聊聊个人最得意的项目;昨天画的架构图倒是派上了点用场。但是涉及到更深的问题解决方案,我还是没回答上来。老板一般想知道候选人的技术边界在哪里,被问懵也很正常。只是我个人有点焦虑,体感很差。面试结束后一直放不下心;HR 没联系我,我也心虚不敢主动问;真想“给个痛快”算了。
上午有面试,老婆没跟我说自己一个人去了产检;然后怕影响到我,一直到面试结束才告知:羊水有点少,医生让她下午去住院。我听完脑子一懵,很懊悔最近让老婆担惊受怕了。吃完午饭我就载她去了住院部,医生说先观察两天,如果羊水继续减少,娃娃必须提前出生。下午两边的妈妈都来了,老婆让我先回家继续学习,家里的事情不要但心,瞬间泪奔了。
2024.9.13
今日 leetcode:256、265
又是一夜未面,妻子、孩子、工作,反复地在脑中回荡。早上我向 HR 问了结果,但是她迟迟不回,只能干等着。谁叫我在“求职”,而不是在"应聘“呢?中午时分终于得到 HR 回复,可以去第四面了;但是大老板中秋后才有空——这一等又得是一个礼拜。最近我又投了几家,但是都没人鸟我;说实在,在这个时间点,它是我唯一的希望了。
之前问我签约事宜的同事好像成功续约了;我也很替他高兴。他告诉我,他直线老板还是很有人情味的:看到他三年期将至就 nominate 他了。怎么说呢,每级老板确实都有流动率指标,但事实上也有卡 bug 的方法的:比如正好在 promotion cycle 里,一般都能续约;虽然晋升渺茫,但是至少能保住饭碗。只可惜我没这个运气。
2024.9.14
今日 leetcode:2390、2056
老婆还在住院,所幸今天测羊水,比之前高了一点;但是还需要观察,至少住院 4 天才能回家。她住在了 40 一天的五人病房里,旁边娃娃晚上一直哭闹,她也睡不好觉。我很心疼她,想让她换到 4000 的单人病房里。但是她执意不肯,说“4000 一晚,我什么时候生不知道的,这得花多少钱?我的事你别操心了。”有妻如此,夫复何求?
我继续回家学习;上一次天天学习的时光应该是 2012 年考研的那段时间吧。年纪大了,我倒是比年轻时更静得下心了;但是身体是真扛不住了,尤其是肩颈,已经疼得我彻夜难免。颈椎好坏有个简单的测试:看你下巴能不能碰到喉结。我试图测了一下,结果脖子直接抽筋了。老婆很担心我,对我说:“即便你找到了工作,身体还能承受得住吗?三年后四十岁,再碰一次裁员,我们又该怎么办?”今天全网都在讨论延迟退休,我们都吃到了多工作三年的福利;三年又三年,每次都只能走一步算一步吧。
2024.9.15
今日 leetcode:2183、2848
今天是中秋假期第一天,我依旧保持着日常学习的节奏。但是,早上就有点偏头痛了;我一直有偏头痛的老毛病,每个月都得疼一次,尤其是焦虑的时候更明显。所幸今天没有面试,不然赶上面试当天真的是太灾难了。最近两边的妈妈帮了大忙,一个烧饭,一个照顾老婆,让我得以安心学习。在最困难的时候家里人还是最坚实的后盾,我真的很感激他们。下午稍微恢复了一点,我又在 boss 直聘里看到一家又 AI 又汽车又金融的外企有 HC;稍许翻了一下一亩三分地,感觉不差,遂 ping 了一下对方,但是 HR 应该也在过中秋节吧。还是希望全世界优秀的公司都能入驻国内,给我们这样的普通人带来更多的机会吧。
2024.9.16
今日 leetcode:1148、1473
今天好消息是老婆出院了:羊水恢复到了正常水平线,可能是前段时间喝水太少导致的。但是娃娃胎位不正,大概率要提前剖腹产;他现在才五斤左右,稍显轻了点,希望能抓紧最后一个礼拜努力长点身体。一切都开始变得好起来了,愿老婆和娃娃都能健康平安。
最近一直在做 leetcode 上 P 家的题目,真的是太难了——上来就三维动态规划。说实在我年轻时也不见得做得出来,更何况现在呢?帮我内推的同事告诉我,我的简历还在筛选阶段;这个岗位要求比较高,可能前面有几百个人排着队。我又看了一下他们家招聘页,只有这个岗位有 HC,根本没有其他 low 一点的选择了。
下午,在 Linkedin 上看到某四大有 HC,但是马上又在朋友圈看到他们家今天大裁员;简直了,这真的是一个魔幻的时代。
2024.9.17
今日 leetcode:146、157
今天是中秋佳节,网络上热议着月饼销量大减的现象。由于我个人对月饼并无太大喜好,因此购买月饼的念头从未在我脑海中浮现。然而,下午与老婆漫步时,我们恰巧路过一家香气扑鼻的烘焙店。我突然意识到,尽管与老婆共度了这么多时光,我却未曾询问过她是否有品尝月饼的愿望。在我的印象中,她一直是个对食物颇为挑剔的人。今年,她仅仅吃了点单位食堂提供的月饼。于是,我轻声问道:“要不要买点月饼尝尝?”她微笑着回答:“这里的月饼口感挺不错的,不过明天就打三折了,我们到时候再来买吧。”她的话让我瞬间意识到,这两天我或许对她的关心有所疏忽。我们携手走过了七个年头,我依稀记得七年前的她,还是个充满青春活力、略带中二气质的大学毕业生。而如今,我们都已踏入中年,面对生活的种种变迁,我们不得不学会更加成熟、稳重地应对。
2024.9.18
今日 leetcode:282、2332
明天就是四面了,说实在大老板面其实也准备不了什么。听 HR 说要英文自我介绍,想了一下好像之前也没像样准备过,就打算今天花一些时间背一下。Kimi 倒是挺好用的,把简历 pdf 上传给它,让它生成英文自我介绍,一下就出来了。我把自己背的内容录下来听了一下,这个重音和停顿还是非常的 Chinglish;英语还是差太多了,希望明天能顺利一点吧。很多人说以后有同声传译软件了就不需要学英语了;但是我觉得英语作为一种删选机制还是会长期存在的。所以无论如何都不能放弃英语学习,尤其是我们非 native English speaker 更是要坚持终身学习。
2024.9.19
今日 leetcode:163、2414
今天总算迎来了终面。1:40,HR 小姐姐带我参观了一下公司,公司里每人一张升降桌还是很有范儿的。闲逛的时候碰到了我两个师兄,年纪大了能遇到熟人真的是很激动的。2 点正式开面,大老板很 nice,全程都没有发表“重要讲话”。和他交流,一下子就能感觉得出他不是那种长期脱离产线的高管,因为很容易和我形成开发上的共鸣。当然,Behavior 面还是必备流程。我以前对这类 BQ 有点抵触,像有些国内厂就是要问你“卷不卷”,也听说北美有些企业会问“喜欢吃草还是吃肉”——吃肉的才是狼。但是后来渐渐意识到,BQ 面也许真的很有必要,比如,网上有“粉红”和“美分”之争,价值观差太多放在一起会很难受。只可惜,国内求职者很多就是要碗饭吃,不可能像某些帝国主义的应聘者那样能挑三拣四。
面试结束后,我走回了家,很累但是躺在床上一点也睡不着觉。我能做的都已尽力了,一切未知就看天命了。
2024.9.20
今日 leetcode:29、170
早上起来得到消息:offer 还在 pending 中;反馈是我用的技术栈太老了……有点震惊,我用的是 next 14 + shadcn/ui,这个怎么再新点呢?不过,回过头来一想,面试的时候我一直在强调我们的技术债务如何苦大仇深:一二三四五,等对方 buy in 了痛点,再提出使用特定技术解决这些遗产问题(日常 design doc 惯性)。面试时候就显得前摆太长了,之后我又没反复强调我解决的方案是最新的技术栈(之前老板不在乎技术新不新);导致别人只记住 CP 技术栈落后这件事了。这种点,不常面试的我真的是完全没有准备到,太痛了。大家平日里无论如何要多去了解业界动态,试试水,不然这类经验不可能凭空获得的。
2024.9.21
今日 leetcode:243
下午和 HR 谈了 offer 的事,很感谢 HR 小姐姐在周末加班帮我搞定 offer。这段时间真的很累,但能在 last day 之前拿到 offer 也是奇迹了。AWX 还是很够意思的,在这个年景给到了我心里价位。只可惜在 CP 三年没能 promotion,title 太低了;因此一开始就只能面 senior 岗,之后再怎么努力也很难在级别上有所突破了。这个倒不怪人家,行业规矩放在那。
我们到了一定年龄后,大家选择工作时不能再简单地看钱了:有些工作可能钱暂时多一点,但是 scope 太小了,发展空间受限,几年后就会反噬。就业市场上对每个年龄段的要求是不一样的,若在特定年龄段没有突破特定限制,那以后就很难了。我们程序员某种意义上需要了解更大的世界,比如人脉、行业动态、市场趋势等,这些都不是简单码代码能实现的。
即使身处最冷的寒冬,我知道自己的内心深处,有一个不可战胜的夏天。 -- 加缪,著名法国小说家
2024.9.22
今日 leetcode:246
早上七点起床去做了一次体检,算是把 CP 仅有的一点福利也给用完了。我最新的体重是是 137 斤,记得去年这时候是 150 的样子,这段时间我也几乎没有运动就是单纯地降体重了。期间在刷手机的时候听说一个消息:某个前同事去了发发奇;今年被 CP 收购了,然后也不出意外地被裁了。一切的行事风格都是那么商业,倒不是说有什么对错,只是没有一丝温情罢了。
2024.9.23
今日 leetcode:359、1014
今天是老婆的生日,阳光明媚,气温 23 摄氏度;难得的好日子,所以我们决定去徐汇的网红街吃顿大餐。午饭的时候,邮箱收到了 AWX 的 offer。无疑,这一天是我漫长时光中最值得欢欣鼓舞的时刻。
自去年年末开始,我便察觉到了形势的异常:部门只有 4 个 L5,上海韩国各两个;其中韩国那俩,一个刚从 4 升到 5,一个被 nominate 了,所以年度 performance 的 PIP 指标大概率在上海这边产生;而且即便我过了第一道坎,还有续约这第二道坎。从那时起,我就开始焦虑了,只能每天晚上回家后刷 leetcode 来减轻一些不安。所幸去年有两同事跑路了,正好吃掉了当年的 PIP 名额——让我没有在三月份速死,给了我一段较长时间的缓冲期。若非如此,我真难想象如何在短短三个礼拜内找到新工作。想想真的是后怕,那段时间即便有小幅的涨薪,即便 CP 股票一直在上扬,我都没有一丝喜悦之情;我甚至觉得自己因工作丧失了最基本的人格感知,一切理因欣喜的事都被我当做了回光返照,甚至连老婆怀孕的消息都让我感到压力倍增。如今,这一切终于结束了……
2024.9.24
今日 leetcode: 252
早上陪老婆去产检,羊水又降了,娃娃也像爸爸一样正经历者人生的磨难。医生建议再住一次院,但是考虑到之前也遇到了同样的问题;在住院部根本休息不好,我们决定先租个胎心仪,回家观察,两天后复查羊水。现在只能走一步算一步,娃娃才 5 斤半,略显瘦小,能在娘胎里待一天是一天。
本来约了 HR 两天后聊赔偿的事,但是和复查冲突了,所以我调整了会面时间至今天下午。有点小意外,是“小 n+1”,可能是我之前听错了成了“小 n”吧;但是确实也不高,在外企里依旧是地板流。我提到了娃娃可能在 last day 之前出生,陪产假能否适当赔偿,但被断然拒绝;另外,我有一笔 Q3 激励的 RSU 在 last day 之后“一天”才发放,这笔钱是否能到账,HR 表示让我自己联系美国的 stock 组,她不负责这块。
晚上,我找到了 CP 前员工的 PIP 离职交流群,询问了群主是否能拿到 RSU;他表示,没戏,我这个情况只能 n+1 走人——赔偿就是踩着法律线的地板流。很难想象这家企业贴的 HC 标的是阿里 P8、P9、P10,但是遣散费就这德行。
2024.9.25
今日 leetcode: 266
昨天我邮件联系了美国的 stock 组,今天终于回复了;他们表示无能为力:这是一开始在合约了规定的条款。看来我只能死心了。随后,我与昨天的群主闲聊了几句,他又抖了点黑料;我也不想传谣,但若这些消息属实,那确实令人感到心寒——始作俑者其无后乎?下午,我与一位新入职的同事交谈了片刻。他坦言:在入职之前就知晓了这里的 PIP 政策;不过即便如此,他还是会来的,因为他也是被之前的公司裁员了,好几个月才找到下家,不能考虑太多了。没办法,经济如此,国内互联网已是明日黄花;现在是买方市场,每个人的命运都已转向。
2024.9.26
今日 leetcode: 2535
昨晚,娃娃的胎心再次出现异常,情况紧急,我们不得不连夜办理了住院手续。吸取上次四人间的拥挤与不便的教训,我决心为老婆选择一个稍好一些的住院环境。然而,我未曾料到红房子的床位竟如此紧张,不仅单人间、双人间已全部满员,连四人间的加床也一张不剩。无奈之下,我们只得在过道上安顿下来——这一次的艰辛,远比上次更甚。
面对这样的困境,我心中五味杂陈,却也只能眼睁睁看着老婆承受这份苦难。今晨,我们及时联系了门诊医生,经过综合考虑,决定于 30 号进行剖腹产手术。
然而,手术前的这段时间,老婆仍需在医院等待,期盼着能排到一个稍微好一些的病房。想到她至少还要在这样的环境中度过五天,我实在是心疼不已。这段时间对我们来说无疑是巨大的考验,一切的一切只愿换来新生命的安全到来。
2024.9.27
今日 leetcode: 293
今天是我在上家公司的 last day,我重走了一遍上班路,在地铁上背单词,然后十点多踏入公司。离职手续进行很简单,不到十一点就已经全部办妥。我穿梭在公司的每一层楼,与那些熟悉的面孔一一道别。
中午时分,我和组里的小伙伴们聚在一起吃了最后的散伙饭。尽管这是告别的时刻,但大家依然保持着往日的激情,餐桌上热烈地讨论着技术话题。我本打算为这顿饭买单,以表达我的感激之情,然而同事们却抢先一步结了账。这让我心中不禁涌起一股暖流,同时也夹杂着一丝歉意:大家平日里都过着节俭的生活,一顿午餐通常只需三十几块,而今天这顿饭的人均消费却近两百元。
饭后,我们像往常一样漫步至 Manner,我点了一份多年未变的“小澳白”。只是这次,我没有带上自己的杯子,无法再享受那 5 块钱的优惠了。这个小小的变化,似乎也在提醒着我,今天的一切都与往日不同了。
最后,我们在欢笑中一一握手告别。这一别,只能再会江湖了……
2024.9.28
今日 leetcode: 270
等啊等,自费病房始终空不出来。这两天,老婆只能屈身于四人病房的加床之上。倒是我丈母娘挺乐观的,她含笑说道:“这孩子似乎天生带有财运。他的爸爸费尽心思,终于为他找到了奶粉钱;而他的妈妈,虽然历经艰辛却没能花了他的钱;现在,就连股票也呈现上涨趋势……”对我来说,吃些苦头并无大碍,我只祈求他们母子能够平安健康。
晚上,又一位前同事联系我,他正处于 PIP 阶段,自称已快承受不住。我只能不住得给他灌鸡汤,为他打气。说实话,作为旁观者,我们很难提供具体且实用的帮助。我所能给的,只是一些宽泛的建议,譬如那句老话,“好死不如赖活着”。我与他分享了亲身经历,并阐明一个观点:坚持下去,只是为了给自己争取更多的缓冲时间,以便找到新的工作机会,从而摆脱现状;而并非为了“适应这里的 PUA”。他和几个月前的我如出一辙,总是患得患失,害怕寻找新工作。但其实,最艰难的部分并非找工作本身,而是下定决心,勇敢迈出那第一步。一旦跨出那一步,找工作便只剩下概率乘以时间的期望值问题了。
2024.9.29
今日 leetcode: 346
今天,老婆终于住进了 LDR 病房;临行前,普通病房的床友们都为她加油助威,仪式感拉得满满的,只有经历过才能体会到生育的不易吧。我们加了一个增值服务——爸爸陪护入产房;然后几个护士轮番教我手术室注意事项,有七步洗手法、如何抱孩子、以及最难的戴无菌手套。她们还分享了一些准爸爸的糗事——在手术室里晕血了,然后医生护士们还得先照顾那位爸爸。不知道我明天会不会成为她们下一段趣事。老婆倒是一切正常,身体也没有异样,中午我们还一起去散步去喝了杯 M-stand。进产房前的一切物件,早就被娃娃妈妈准备的一应俱全,我们只等着新生命的到来了。
2024.9.30
今日leetcode:1064
早晨 8 点 52 分,娃娃一声啼哭,宣誓着新生命的降临;我颤抖着双手戴上了无菌手套,为他剪下了脐带。然后,回到手术台旁,紧紧握住老婆的手,告诉她:“儿子 6 斤半,黄金体重”。娃娃妈含着泪说:“长得磕碜吗?”(这颜控……)娃娃出生时,全身红紫色,很小的一只;但眼角很宽,皮肤光洁,一看就是帅小伙。手术很成功,二十分钟后就结束了;母子随后被转移到了观察室里。娃娃出生后缺少安全感,所以最好有肌肤接触;医生要我解开衣襟,让娃娃趴在了我身上。人类幼崽还是很有趣的:出厂设置极简——啥都不会,只保留了一个本能——吸奶;这好大儿竟在老父亲胸口边爬边种草莓。一个小时后,我们回到了病房,老婆还需要休息一会儿;大家便离开了病房,开始围着娃娃看。这时候,娃娃全身的红紫色已然退去,一双小手白皙粉嫩,面色清秀很像爸爸,看样子注定要迷倒万千少女了。全家人都乐呵呵地围着娃娃,连他竖个兰花指都能逗乐奶奶;外婆更是把娃娃拉粑粑的片段都给录了下来分享给全家人。最最重要的是,他出生后 A 股竟然当天涨了 8 个点,大家都啧啧称赞这娃娃自带财运。新的篇章从此开始……
来源:juejin.cn/post/7430031817254944805
One vs Taro vs Uniapp:跨平台三巨头对决,谁能成为你的终极开发利器?
随着移动端和Web应用的多样化发展,跨平台开发已经成为越来越多开发者的选择。写一套代码,运行在多个平台上,能大大提升开发效率、节省时间。那么,问题来了:在众多的跨平台框架中,究竟该选择哪个?今天在 GitHub 上看到了一个新的多端框架,ONE,号称可以统一全平台
索性,我们就来聊聊三个热门框架——Taro、One和Uniapp,看看它们各自的优势和适用场景,帮你找到最适合的跨平台解决方案。
为什么选择Taro、One和Uniapp?
这三者都是当前跨平台开发领域的主力军,但它们各自的定位和优势略有不同。Taro,由京东旗下的凹凸实验室推出,基于React,特别擅长小程序和H5的跨平台开发,国内开发者使用率很高;One,作为一款新兴的React框架,专注于Web、移动端和桌面端的跨平台开发,且具备本地优先的数据同步特性;Uniapp,由DCloud开发,基于Vue,主打“一次开发,多端适配”,在国内的小程序开发中占有一席之地。
接下来,我们从多个维度对比一下它们,看看哪个框架更适合你的项目需求。
平台覆盖范围对比
Taro的最大特点是对小程序支持非常全面,不仅支持微信小程序,还兼容支付宝、百度、字节跳动等多种小程序平台。此外,它还支持H5和React Native开发,因此如果你需要同时开发多个小程序和移动端App,Taro是一个非常合适的选择。
One在平台覆盖上更加广泛,它不仅支持Web、iOS、Android,还支持桌面应用程序的开发。然而,One目前并不支持小程序开发,所以如果你项目的重点是小程序,One可能不适合你。
Uniapp则也是小程序开发的强者,支持包括微信、支付宝、钉钉在内的多个小程序平台。同时,Uniapp还支持H5、iOS、Android,甚至可以打包为App、桌面应用,几乎覆盖了所有主流平台。对于那些需要开发多端应用,尤其是小程序的开发者来说,Uniapp可以说是一个“全能型选手”。
总结:如果你的项目主要涉及小程序开发,Taro和Uniapp更胜一筹,Taro在React生态下表现优异,Uniapp则在Vue生态中一骑绝尘;而如果你的项目重心是跨Web、移动端和桌面应用,One的优势更为明显。
技术栈对比——React vs Vue
框架选择的背后,往往与技术栈密不可分。对于大部分开发者来说,选择技术栈往往决定了上手的难度和开发的舒适度。
Taro基于React,提供了类似React的开发体验。对于习惯React的开发者来说,Taro非常友好,语法、组件化思路与React保持一致,你可以毫无缝隙地把已有的React经验直接应用到Taro项目中。
One同样基于React,但它做到了更深层次的跨平台统一,支持Web、移动端和桌面端的无缝切换,并且主打本地优先的数据处理,避免了频繁的API调用和复杂的同步逻辑。如果你习惯了React,并且希望进一步简化跨平台开发中的数据处理,One会是一个非常强大的工具。
Uniapp则基于Vue,对于喜欢Vue的开发者来说,Uniapp的上手难度很低,而且Uniapp的语法风格与Vue保持高度一致,你可以直接复用已有的Vue项目中的代码和经验。
总结:喜欢React的开发者可以考虑Taro或One,两者在跨平台能力上各有侧重;而如果你偏好Vue,那么Uniapp无疑是更理想的选择。
跨平台代码复用率对比
在跨平台开发中,代码复用率是开发者最关心的问题。Taro、One和Uniapp在这方面的表现都有各自的亮点。
Taro的代码复用率相对高,尤其是在小程序和H5应用中,大部分代码可以共享。但如果涉及到React Native,你仍然需要做一些针对平台的适配工作。
One则走得更远,它通过React和本地优先的数据处理模式,最大程度地减少了跨平台开发中的代码分歧。你可以只写一套代码,就能让应用无缝运行在Web、移动端和桌面端,并且无需为离线数据同步操心,这让One的代码复用率和开发效率非常出色。
Uniapp在代码复用率上表现也非常不错,它支持“一次开发,多端适配”,通过Vue语法几乎可以覆盖所有平台。只需要根据不同平台的差异做少量适配,便能确保项目在多端无缝运行。
总结:如果你希望最大化代码复用率,One在Web、移动和桌面端的表现最优;而如果你需要同时兼顾小程序和H5、App开发,Taro和Uniapp都可以满足需求。
性能对比
Taro和Uniapp在小程序和H5上的性能表现都非常优秀,接近原生体验。在React Native和App开发中,Taro的性能也相对稳定。
One则主打性能无缝衔接,尤其是本地优先的特性让它在处理大量数据时能表现得更加流畅。相比Taro和Uniapp,One的Web和桌面端性能更为出色,移动端的性能也接近原生。
总结:在小程序领域,Taro和Uniapp表现优秀;而在处理跨平台的Web、移动和桌面应用时,One的性能表现更胜一筹。
代码示例——如何选择适合的框架
让我们通过一个简单的代码示例,看看Taro、One和Uniapp在实际开发中的差异。
Taro 代码示例:
import { Component } from '@tarojs/taro';
import { View, Button } from '@tarojs/components';
class TodoApp extends Component {
state = {
todos: []
};
addTodo = () => {
this.setState({ todos: [...this.state.todos, '新任务'] });
};
render() {
return (
<View>
<Button onClick={this.addTodo}>添加任务</Button>
<View>
{this.state.todos.map((todo, index) => (
<View key={index}>{todo}</View>
))}
</View>
</View>
);
}
}
One 代码示例:
import { useLocalStore } from 'one-stack';
function TodoApp() {
const [todos, setTodos] = useLocalStore('todos', []);
function addTodo() {
setTodos([...todos, '新任务']);
}
return (
<div>
<button onClick={addTodo}>添加任务</button>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
</div>
);
}
Uniapp 代码示例:
<template>
<view>
<button @click="addTodo">添加任务</button>
<view v-for="(todo, index) in todos" :key="index">{{ todo }}</view>
</view>
</template>
<script>
export default {
data() {
return {
todos: []
};
},
methods: {
addTodo() {
this.todos.push('新任务');
}
}
};
</script>
可以看到,Taro和Uniapp在小程序和多端开发上拥有强大的兼容性,而One则在Web和桌面应用中拥有更广泛的适用场景。不同的框架在开发体验上虽然有所不同,但总体而言,它们都能够较好地实现跨平台开发的目标。
生态与社区支持
选择一个框架,不仅要看它本身的功能,还要看其背后的生态和社区支持,因为这些决定了在遇到问题时能否快速找到解决方案,以及框架的未来发展潜力。
Taro依托于京东的支持,经过多年的迭代更新,拥有一个非常活跃的社区。你可以在社区中找到丰富的插件、第三方组件库和详细的教程文档。如果你在小程序开发中遇到问题,基本上都能通过Taro的社区找到解决方案。
One虽然是一个新兴的框架,但它的开发团队对React社区有着深厚的积累。因为基于React,它可以无缝利用React的生态,包括丰富的第三方库、开发工具和强大的社区支持。不过,作为一个新框架,One的社区规模还不如Taro和Uniapp庞大,但由于其独特的跨平台能力,未来的生态成长潜力不容小觑。
Uniapp的社区在国内极其庞大,DCloud团队也在持续更新Uniapp的功能和插件库。它的文档详细而完善,社区中也有大量的开发者分享经验,解决实际开发中的问题,尤其是在小程序开发领域,Uniapp几乎拥有无可匹敌的生态优势。
总结:如果你注重社区和生态的完善性,Taro和Uniapp的社区非常活跃,拥有丰富的插件和第三方支持;而如果你追求跨平台开发的前沿技术,One虽然较新,但凭借React的生态也有着很强的社区支持潜力。
结论:如何选择适合你的跨平台开发框架?
在Taro、One和Uniapp三者之间,选择最适合的框架取决于你的项目需求和技术栈。
- • 如果你以小程序开发为核心,并且希望使用React进行开发,那么Taro是你的最佳选择,尤其是当你还需要兼顾H5和移动端应用时,Taro的表现也非常出色。
- • 如果你的项目涉及Web、移动端和桌面端的统一开发,并且你希望有更好的代码复用率和数据同步机制,那么One会是一个颠覆性的选择,它通过本地优先的设计,解决了许多跨平台开发中的数据同步问题,提升了开发效率。
- • 如果你更习惯Vue,并且需要覆盖从小程序到H5、App等多个平台,Uniapp无疑是一个全能的选手。它在国内有着广泛的应用,特别是在小程序开发中拥有明显优势。
最终,选择哪一个框架,还是要根据你团队的技术栈、项目需求以及你对跨平台性能和代码复用率的要求做出判断。无论是Taro、One还是Uniapp,它们都能为你的跨平台开发提供强大的支持。
希望这篇文章能帮你理清思路,让你在框架选择上不再迷茫。如果你还在犹豫,不妨亲自试用一下这三个框架,结合实际开发需求和团队技术背景,相信你一定能找到那个“最合拍”的开发工具。
你觉得这三个框架哪个更适合你的项目呢?有任何问题或者经验分享,欢迎在评论区留言,我们一起讨论交流!
来源:juejin.cn/post/7420971044158193664
每一个失业的前端er都必须有一个稳定盈利的独立开发项目
如题,最近非常焦虑,因为考试临近了,所以只好来祸害一下网友了
俺从2023年离职,经历了考研,独立开发,remote,好几段经历
首先是考研,去年考的其实还行,但还是复试被刷,至今被刷原因未知,盲猜是因为本科是民办三本吧
然后remote就是找了个美国的区块链公司,但是因为四月份我忙着搞调剂,过程十分煎熬,根本无心顾暇remote那边天天开会的节奏,所以只能离职,当然啦,最终也没调剂上
这都不是重点,重点是独立开发
从我离职到现在,也快两年了,聪明的人已经发现了,整个互联网技术栈这两年可以说毫无变化,新的端没有,新的框架没有,新的红利也没有,新的独角兽公司也没有
道理很简单,因为现在是僧多粥少的时代,每个人手机上就固定几个app,而且都是存量状态(不需要推翻重来,只需要shi山跳舞)
与此同时,还有若干小公司不断倒闭
懂了吧,现在是需求没了,业务没了,招聘的公司没了
独立开发就只不过是,没有业务,我们自己发现制造业务罢了
但是呢,会更难,因为,资本虽然是傻逼,但它们也不是完全没脑子,如果轻易能成功,他们就不需要跑路了
现实就是,我朋友圈有很多独立开发的,推特上也有很多,但能做到稳定盈利的人,几乎为0
有的是卖小册,有的是搞博客,还有开公司做面试辅导的,也有外包接活的,也有收费技术咨询的
这些统统都是噶韭菜——因为我说的很清楚了,现在是业务没了,是需求没了,但凡不制造需求的,都是瞎扯
——所以我把c站卖了,c站转让前日活5w,但是动漫行业实在太卷了,各种各样的竞品,让我自己都不想看番,更别提服务给他人看了
之前在携程,我的老板和我说,你就当独立创业,携程三万人就是你的第一批客户,我觉得老板说的没错,就是比起b端,我更喜欢c端的用户
所以毫无疑问,我不可能再回去写前端框架了,纯粹浪费时间,浪费我的❤
唉,说了这么多,总而言之,言而总之
回到题目,那就是,每个人失业的前端er都必须有一个稳定盈利的独立开发项目
我也在开新坑了,敬请期待~
来源:juejin.cn/post/7426258631161528335
在老的Node.js服务器里“加点Rust”,我的服务性能飙升近 80%
你有没有遇到过这样的情况?服务器跑着跑着就卡了,明明只是一些普通的操作,却让资源“飚红”,甚至快撑不住了。特别是当你用JavaScript或者Python这些脚本语言写的服务器,遇到CPU密集型任务时,性能瓶颈似乎更是无可避免。这时候,是不是觉得有点力不从心?
今天,我们安利一个解决方案——Rust!一种速度快、效率高的编程语言。它有点像是给你的Node.js或者Python服务器加了“肌肉”,尤其适合处理高强度的运算任务。下面,我就给大家讲讲如何一步步把Rust“融入”到现有的服务器里,用简单的策略大幅度提升性能。
引入Rust的三步策略
在这个策略中,我们从“0”开始,逐步引入Rust,分别通过Rust CLI工具和Wasm模块来提升服务器的性能。总的原则是:每一步都不搞大改动,让你的老服务器既能“焕发新生”,又能保持现有的代码框架。
第0步:从Node.js服务器开始
假设我们现在有一个Node.js服务器,用来生成二维码。这个需求其实并不复杂,但在高并发的情况下,这样的CPU密集型任务会让JavaScript显得吃力。
const express = require('express');
const generateQrCode = require('./generate-qr.js');
const app = express();
app.get('/qrcode', async (req, res) => {
const { text } = req.query;
if (!text) {
return res.status(400).send('missing "text" query param');
}
if (text.length > 512) {
return res.status(400).send('text must be <= 512 bytes');
}
try {
const qrCode = await generateQrCode(text);
res.setHeader('Content-Type', 'image/png');
res.send(qrCode);
} catch (err) {
res.status(500).send('failed generating QR code');
}
});
app.listen(42069, '127.0.0.1');
基准测试:在纯Node.js的情况下,这个服务每秒能处理1464个请求,内存占用也不小。虽然勉强能跑起来,但一旦用户多了,可能会明显感觉到卡顿。
第1步:引入Rust CLI工具,效率提升近80%
这里的策略是保留Node.js的框架不变,把处理二维码生成的那段代码用Rust写成一个独立的命令行工具(CLI)。在Node.js中,我们直接调用这个CLI工具,分担高强度的计算工作。
/** qr_lib/lib.rs **/
use qrcode::{QrCode, EcLevel};
use image::Luma;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
pub type StdErr = Box<dyn std::error::Error>;
pub fn generate_qr_code(text: &str) -> Result<Vec<u8>, StdErr> {
let qr = QrCode::with_error_correction_level(text, EcLevel::L)?;
let img_buf = qr.render::u8>>()
.min_dimensions(200, 200)
.build();
let mut encoded_buf = Vec::with_capacity(512);
let encoder = PngEncoder::new_with_quality(
&mut encoded_buf,
// these options were chosen since
// they offered the best balance
// between speed and compression
// during testing
CompressionType::Default,
FilterType::NoFilter,
);
img_buf.write_with_encoder(encoder)?;
Ok(encoded_buf)
}
效果:重写后,我们的处理性能直接飙升到了每秒2572个请求!这是一个显著的提升,更让人欣慰的是,内存占用也跟着降了下来。Rust的高效编译和内存管理,确实比JavaScript强太多了。
实现步骤:
- 首先,用Rust编写二维码生成的核心逻辑代码。
- 将这段Rust代码编译成一个可执行的CLI工具。
- 在Node.js代码中,通过子进程调用CLI工具,直接拿到生成的结果。
在Node.js中调用Rust CLI工具的代码示例如下:
const { exec } = require('child_process');
exec('./qr_generator_cli', (error, stdout, stderr) => {
if (error) {
console.error(`执行出错: ${error}`);
return;
}
console.log(`生成的二维码数据: ${stdout}`);
});
这个方法就像是给Node.js加了一个“外挂”,而且几乎不需要改动现有代码。也就是说,你可以在不动大框架的情况下,得到Rust的性能优势。
第2步:编译Rust到WebAssembly(Wasm),性能提升再进一步
在第1步中,我们通过CLI工具调用了Rust,但依旧会产生一定的通信开销。所以,接下来,我们可以进一步优化,将Rust代码编译成WebAssembly(Wasm)模块,并在Node.js中直接调用它。这样,整个过程就在内存中运行,不用通过子进程调用CLI,速度进一步提升。
效果:使用Wasm后,处理性能再上升到了每秒2978个请求,而内存使用依旧维持在较低水平。
实现步骤:
- 将Rust代码编译为Wasm模块。可以使用
wasm-pack
这样的工具来帮助生成。
- 将Rust代码编译为Wasm模块。可以使用
- 在Node.js中,通过
wasm-bindgen
等工具直接加载并调用Wasm模块。
- 在Node.js中,通过
Node.js中加载Wasm模块的代码示例如下:
const fs = require('fs');
const wasmBuffer = fs.readFileSync('./qr_generator_bg.wasm');
WebAssembly.instantiate(wasmBuffer).then(wasmModule => {
const qrGenerator = wasmModule.instance.exports.qr_generate;
console.log(qrGenerator('Hello, Rust with Wasm!'));
});
这种方法让我们完全绕过了CLI的通信环节,直接把Rust的性能用在Node.js中。这不仅提升了效率,还让代码更加紧凑,减少了延迟。
思考
通过以上三步策略,我们可以在不完全推翻现有代码的前提下,逐步引入Rust,极大地提升服务器的性能。这个过程既适用于Node.js,也可以推广到其他语言和环境中。
为什么这个方法特别值得尝试呢?首先,它成本低。你不需要重写整个系统,只需要对瓶颈部分进行改进。其次,效果明显,尤其是对那些经常“吃力”的功能。最后,这个方法是可扩展的,你可以根据实际情况,灵活选择用CLI还是Wasm的方式来引入Rust。
所以,如果你的服务器正被性能问题困扰,不妨试试这个三步引Rust法。正如一位资深开发者所说:“Rust不仅让你的服务器跑得更快,还让代码变得更加优雅。”
来源:juejin.cn/post/7431091997114843151
将B站作为曲库,实现一个畅听全网歌曲的音乐客户端
仓库地址
前言
在很久之前做了一个能够免费听周杰伦歌曲的网页,经历了各种歌曲源失效的问题之后,换了一种实现思路,将B站作为曲库,开发一个应用,这样只要B站不倒,就可以一直白嫖歌曲了。
实现思路
- B 站上有很多的音乐视频,相当于一种超级全的音乐聚合曲库(索尼直接将 B 站当做网盘,传了 15w 个视频)
- 对这些视频进行收集制作成歌单
- 无需登录即可完整播放,无广告
- 使用 SocialSisterYi 整理的 B 站接口文档,直接就可以获取和搜索 B 站视频数据
功能
- 播放器
- 基础功能(播放,暂停,上一首,下一首)
- 播放列表
- 单曲循环,列表循环,随机播放
- 进度拖动
- 计时播放
- 搜索
- 名称关键字搜索
- 歌单
- 歌单同步
- 歌单广场(由用户贡献分享自己的歌单)
技术栈
- Flutter
缺陷
- 没有 IOS 版本(上架太贵了)
- 没有歌词
- 云同步配置麻烦一些,(隐私与便利不可兼得)
UI
警告
此项目仅供个人学习使用,请勿用于商业用途,否则后果自负。
鸣谢致敬
- SocialSisterYi 感谢这个库的作者和相关贡献者
- 感谢广大 B 站网友们提供的视频资源
来源:juejin.cn/post/7414129923633905675
THREE.JS——让你的logo切割出高级感
灵感图

每次都根据灵感图写代码,我都快成灵感大王了,本文较长,跨度较大,效果较好,请耐心看完,本文阶段代码有tag可以分部查看
前言
这是B站的一段视频,用3D渲染的方式表达各个大厂的logo如何制作出来的,其中提取出一小段,用于本文的灵感,就是这个图的切割效果,下文不包含激光的圆圈和工作平台,只有切割的光线、切割效果和分离动画,灵感图中切割的部分是超过logo的,如果有UI设计师,可以让设计师给提供分段的svg,我孤军奋战没有那么些资源,文中的点位都是从logo的svg文件获取的,场景创建就不赘述了,以前的文章也讲过很多次,那么我们开始吧
准备工作
- threejs
- ts
- vite
找一个这个小鸟的svg文件。
将svg文件的点位获取出来并将svg加入到场景中
渲染svg
// 加载模型
const loadModel = async () => {
svgLoader.load('./svg/logo.svg', (data) => {
const material = new THREE.MeshBasicMaterial({
color: '#000',
});
for (const path of data.paths) {
const shapes = SVGLoader.createShapes(path);
for (const shape of shapes) {
const geometry = new THREE.ShapeGeometry(shape);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh)
}
}
renderer.setAnimationLoop(render)
})
}
loadModel()
渲染结果

svg加载出来后的shape
就是组成当前logo的所有关键点位信息,接下来要做的是将这个logo以正确的角度放置在场景,再将这些关键点位生成激光运动路径,比如一个圆弧,是一个贝塞尔曲线,有两个定点,几个手柄,通过不同的角度组成曲线,而我们要做的是一条布满点位的曲线作为运动路径

获取曲线点位
这里用到的api是# CubicBezierCurve
贝塞尔曲线的基类Curve对象提供的方法getPoints
.getPoints ( divisions : Integer ) : Array
divisions -- 要将曲线划分为的分段数。默认是 5.
为了更方便的查看我们创建的点位,我们将生成的点位信息创建一个cube
// 加载模型
const loadModel = async () => {
...
for (const curve of shape.curves) {
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/
const points = curve.getPoints(100);
console.log(points);
for (const v2 of points) {
const geometry = new THREE.BoxGeometry(10, 10, 10);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(v2.x, v2.y, 0)
scene.add(cube);
}
}
...
}
}
renderer.setAnimationLoop(render)
})
}
loadModel()
从图中可以看出,现在cube已经绕着logo围成一圈了,但是有一个现象,就是路径长的地方cube比较稀疏,而路径比较短的曲线cube比较密集,上面代码创建的关键点位信息都是以100的数量创建,所以会导致这种情况,曲线的疏密程度决定将来激光的行走速度,为了保证不管多长的路径,他们的行走速度是一样的,那么我们需要动态计算一下到底该以多少个点位来生成这条路径
...
const length = curve.getLength ();
const points = curve.getPoints(Math.floor(length/10));
...
在遍历curve的时候,通过getLength
获取曲线的长度,根据长度的不同,决定分段的点位数量,这样就保证了点位之间的距离是一样的,将来激光行走的速度也是可以控制成一样的,速度一样,距离越短,越先完成,当然你想让所有激光都同时完成,那是不需要让分割的点位分布均匀的。
提取点位信息
由于之前我们获取到了所有的点位信息,那么是不要加载原有的svg生成的logo,所以我们现在要将获取到的分割点,改为vector3,并缩小一下logo,这样方便以后操作
// 新建一个二维数组用于收集组成logo的点位信息
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector2[] = []
// 用于计算box3的点位合集
let divisionPoints: THREE.Vector3[] = []
// 将遍历贝塞尔曲线的地方再改造一下
let list: THREE.Vector3[] = []
/*
* .getPoints ( divisions : Integer ) : Array
* divisions -- 要将曲线划分为的分段数。默认是 5.
*/
const length = curve.getLength();
const points = curve.getPoints(Math.floor(length / 20));
for (const v2 of points) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
}
paths.push(list)
制作底板并将logo和底板统一放在视图中心
在此之前需要先定义几个变量,用于之后的使用
const logoSize = new THREE.Vector2()
const logoCenter = new THREE.Vector2()
// 底板厚度
const floorHeight = 3
let floor: THREE.Mesh | null
// 底板比logo的扩张尺寸
let floorOffset = 8
根据点位信息收集logo 的信息
根据之前收集的点位信息创建出底板和logo
const handlePaths = () => {
const box2 = new THREE.Box2();
box2.setFromPoints(divisionPoints)
box2.getSize(logoSize)
box2.getCenter(logoCenter)
createFloor()
}
创建地板和logo
const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);
const material = new THREE.MeshLambertMaterial({ color: 0x6ac3f7 });
floor = new THREE.Mesh(geometry, material);
scene.add(floor);
createLine()
}
const createLine = () => {
const material = new THREE.LineBasicMaterial({
color: 0x0000ff
});
const points: THREE.Vector3[] = [];
divisionPoints.forEach(point => {
points.push(new THREE.Vector3(point.x, floorHeight, point.y))
})
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, material);
const linePos = logoSize.clone().divideScalar(-2)
line.position.set(linePos.x, 0, linePos.y)
scene.add(line);
}
我们之前加载的svg已经没有用了,只是为了提供点位信息,所以需要再根据整理后的点位信息创建一个logo的Line
对象
效果图
绘制激光
创建4(可自定)条激光,起点从底板上方30的位置,结束于logo,然后结束的点位随着logo的点位进行改变,从而实现激光运动的效果,提前先确定一下激光起点,
判断起点
由于激光数量可以自定,那么我们需要自定义一个激光的数量,当前用的数量是10,而要配置不同数量的激光,位置就需要有一定的规则,下面代码是创建了一个圆弧,以激光数量为基础,在圆弧上获取相应的点位,这样不管多少个激光,都可以从这个圆弧上取起点位置,圆弧的半径是以logo为基础向内缩进的,而结束点,目前定在底板的下面。
// 激光组
const buiGr0up = new THREE.Gr0up()
// 激光起点相对于logo缩进的位置
const buiDivide = 3
// 决定激光起点距离场景中心的距离
const buiOffsetH = 30
// 决定有几条激光
const buiCount = 10
const createBui = () => {
// 创建一个圆弧,将来如果有很多激光,那么起点就从圆弧的点位上取
var R = Math.min(...logoSize.toArray()) / buiDivide; //圆弧半径
var N = buiCount * 10; // 根据激光的条数生成圆弧上的点位数量
// 批量生成圆弧上的顶点数据
const vertices: number[] = []
for (var i = 0; i < N; i++) {
var angle = 2 * Math.PI / N * i;
var x = R * Math.sin(angle);
var y = R * Math.cos(angle);
vertices.push(x, buiOffsetH, y)
}
// 创建圆弧的辅助线
initArc(vertices)
for (let i = 0; i < buiCount; i++) {
const startPoint = new THREE.Vector3().fromArray(vertices, i * buiCount * 3)
const endPoint = new THREE.Vector3()
endPoint.copy(startPoint.clone().setY(-floorHeight))
// 创建cube辅助块
const color = new THREE.Color(Math.random() * 0xffffff)
initCube(startPoint, color)
initCube(endPoint, color)
}
}
效果图
每两个相同的颜色就是当前激光一条激光的两段
line2
下面该创建激光biu~
,原理上是一条可控制宽度的线,虽然threejs中的线条材质提供的linewidth来控制线宽,但是属性下面有说明,无论怎么设置,线宽始终是1,所以我们要用另一种表现形式:Line2
.linewidth : Float
控制线宽。默认值为 1。
由于OpenGL Core Profile与 大多数平台上WebGL渲染器的限制,无论如何设置该值,线宽始终为1。
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
...
const createLine2 = (linePoints: number[]) => {
const geometry = new LineGeometry();
geometry.setPositions(linePoints);
const matLine = new LineMaterial({
linewidth: 0.002, // 可以调整线宽
dashed: true,
opacity: 0.5,
color: 0x4cb2f8,
vertexColors: false, // 是否使用顶点颜色
});
let biu = new Line2(geometry, matLine);
biuGr0up.add(biu);
}
调用initBiu~
createLine2([...startPoint.toArray(),...endPoint.toArray()])
效果图
准备工作大致就到此结束了,接下来要实现的效果是激光运动
、激光发光
、logo切割
。
激光效果
首先先把激光的数量改为4,再将之前收集到的logo坐标点位分成四份,每根激光负责切割其中一份,切割的过程就是将激光的endpoint进行改变。
激光运动
计算激光结束点位置
在创建好激光后调用biuAnimate
方法,这个方法更新了激光的结束点,遍历之前从svg上获取的点位信息,将这些点位以激光的数量等分,再将这些点位信息作为Line2的顶点信息,通过setInterval的形式更新到激光的Line2
const biuAnimate = () => {
console.log('paths', paths, divisionPoints);
// biuCount
// todo 这里要改成points这样的 每次切割完 收缩一下激光,再伸展出来
const allPoints = [...divisionPoints]
const len = Math.ceil(allPoints.length / biuCount)
for (let i = 0; i < biuCount; i++) {
const s = (i - 1) * len
const points = allPoints.splice(0, len);
const biu = biuGr0up.children[i] as Line2;
const biuStartPoint = biu.userData.startPoint
let j = 0;
const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const attrPosition = [...biuStartPoint.toArray(), ...new THREE.Vector3(point.x, floorHeight/2, point.y).add(getlogoPos()).toArray()]
uploadBiuLine(biu, attrPosition)
j++
} else {
clearInterval(interval)
}
}, 100)
}
}
// 更新激光信息
const uploadBiuLine = (line2: Line2, attrPosition) => {
const geometry = new LineGeometry();
line2.geometry.setPositions(attrPosition);
}
效果图

根据激光经过的路径绘制logo
首先隐藏掉原有的logo,以每一条激光为维度,创建一个THREE.Line
,这样我们就有了4条曲线,在每次激光经过的点作为这条曲线的节点,去更新BufferGeometry
。
创建激光的部分代码
for (let i = 0; i < biuCount; i++) {
...
// 创建线段
const line = createLine()
scene.add(line)
const interval = setInterval(() => {
if (j < points.length) {
const point = points[j]
const endArray = new THREE.Vector3(point.x, floorHeight / 2, point.y).add(getlogoPos()).toArray()
const attrPosition = [...biuStartPoint.toArray(), ...endArray]
...
// 获取原有的点位信息
const logoLinePointArray = [...(line.geometry.attributes['position']?.array||[])];
logoLinePointArray.push(...endArray)
// 更新线段
line.geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(logoLinePointArray), 3))
j++
} else {
clearInterval(interval)
}
}, 100)
}

从图中可以看到,每根曲线之间的衔接做的并不是很到位,所以稍微改造一下代码,将上一根线的最后一个点位给到当前的线,
const points = allPoints.splice(0, len);
// allPoints是截取到上一轮点位的其余点位,所以第一个就是当前激光相邻的第一个点
if(i<biuCount-1) {
points.push(allPoints[0])
} else {
//最后一条曲线需要加的点是第一条线的第一个点
points.push(divisionPoints[0])
}

logo分离
激光切割完毕后,logo和底板将分离,之前想用的是threeBSP
进行布尔运算进行裁切,但是对于复杂的logo使用布尔运算去裁切太消耗资源了,简单的几何形状可以。
创建裁切的多余部分
创建裁切的过程其实就是新增和删除的过程,新增一个logo和多余部分,再将原有的底板删除掉
这里多余的部分使用shape的孔洞,底板尺寸生成的形状作为主体,logo作为孔洞,结合起来后,将得到的shape进行挤压
创建logo和多余部分的几何体
在外部创建logo和多余部分的shape
// 用于创建logo挤压模型的形状Shape
const logoShape = new THREE.Shape()
// 用于创建多余部分的挤压模型形状
const moreShape = new THREE.Shape()
loadModel
方法新增代码,用于收集logoShape的点位信息
// 加载模型
const loadModel = async () => {
...
for (let i = 0; i < points.length - 1; i++) {
const v2 = points[i]
if (v2.x !== 0 && v2.x && v2.y !== 0 && v2.y) {
// logo 太大了,缩小一下,这里不建议用scale缩svg,直接缩向量,后面依赖向量的元素都需要重新绘制
v2.divideScalar(20)
const v3 = new THREE.Vector3(v2.x, 0, v2.y)
list.push(v3)
divisionPoints.push(v2)
if (i === 0) {
logoShape.moveTo(v2.x, v2.y)
} else {
logoShape.lineTo(v2.x, v2.y)
}
}
}
...
}
createFloor
方法创建moreMesh多余部分的挤压几何体
const createFloor = () => {
const floorSize = logoSize.clone().addScalar(floorOffset)
const geometry = new THREE.BoxGeometry(floorSize.width, floorHeight, floorSize.height);
floor = new THREE.Mesh(geometry, logoMaterial);
// scene.add(floor);
moreShape.moveTo(floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, floorSize.y / 2);
moreShape.lineTo(-floorSize.x / 2, -floorSize.y / 2);
moreShape.lineTo(floorSize.x / 2, -floorSize.y / 2);
const path = new THREE.Path()
const logoPos = new THREE.Vector3(logoCenter.x, floorHeight / 2, logoCenter.y).negate()
// logo实例
logoMesh = createLogoMesh(logoShape)
logoMesh.position.copy(logoPos.clone().setY(floorHeight))
logoMesh.material = new THREE.MeshLambertMaterial({ color: 0xff0000, side: THREE.DoubleSide });
scene.add(logoMesh);
// 孔洞path
divisionPoints.forEach((point, i) => {
point.add(logoCenter.clone().negate())
if (i === 0) {
path.moveTo(point.x, point.y);
} else {
path.lineTo(point.x, point.y);
}
})
// 多余部分添加孔洞
moreShape.holes.push(path)
// 多余部分实例
moreMesh = createLogoMesh(moreShape)
// moreMesh.visible = false
scene.add(moreMesh)
}
经过以上的改造,画面总共分为三个主要部分,激光、多余部分、logo。
大概效果就是这样的,再加上动画,让激光有收起和展开,再加上切割完以后,多余部分的动画,那这篇教程基本上就完事儿了,下面优化的部分就不一一展示了,可以看最终的效果动图,也可以从gitee上将代码下载下来自行运行
推特logo
抖音 logo
github logo
动图比较大,可以保存在本地查看
项目地址
来源:juejin.cn/post/7337169269951283235
BOE(京东方)首度全面解读ADS Pro液晶显示技术众多“真像” 倡导以创新推动产业高价值增长
10月28日,BOE(京东方)“真像 只有一个”ADS Pro技术品鉴会在上海举行。BOE(京东方)通过打造“光影显真”、“万像归真”、“竞速见真”三大场景互动区,以及生动鲜活的实例和现场体验,揭示了众多“真像”,解读了行业误区以及消费者认知偏差,不仅全面展示了ADS Pro技术在高环境光对比度、全视角无色偏、高刷新率和动态画面优化等方面的卓越性能表现,以及液晶显示技术蓬勃的生命力,更是极大推动了全球显示产业的良性健康发展。活动现场,BOE(京东方)高级副总裁、首席技术官刘志强,京东集团副总裁、京东零售家电家居生活事业群总裁李帅等出席并发表致辞,并在行业嘉宾、媒体伙伴的见证下,共同发起“产业高价值增长倡议”,标志着中国显示产业开启从价格竞争走向价值竞争的高质发展新时代。
BOE(京东方)高级副总裁、首席技术官刘志强表示,人类对真相的探究永无止境,而显示技术的“真像”也需要还原最真实的色彩和场景。作为中国大陆液晶显示产业的先行者和领导者,BOE(京东方)在市场规模、出货量、技术、应用等方面遥遥领先,如今,有屏的地方就有京东方,如何选好屏,也成为当下消费者最关注的议题之一。作为三大技术品牌之一,BOE(京东方)自主研发的ADS Pro是应用最广的主流液晶显示技术,搭载ADS Pro技术的产品目前全球出货量最高。BOE(京东方)通过不断技术创新,依托ADS Pro技术的超高环境光对比度、超广视角、超高刷新率等优势,不断迭代升级并推出包括BD Cell、UB Cell、以及ADS Pro+Mini LED背光等创新显示解决方案,引领显示行业技术发展潮流,带领中国屏幕走向全球。
京东集团副总裁、京东零售家电家居生活事业群总裁李帅表示,作为BOE(京东方)自主研发的高端显示技术,ADS Pro在高对比度、更广色域、超广全视角、超低反射率等方面的技术特性,极大程度满足了用户对于高端电视的消费需求,今年618期间,ADS Pro电视在京东的成交额同比增长超过3倍。京东与BOE(京东方)共同打造了全域内容营销矩阵,通过整合京东站内外内容,用好内容积攒产品口碑。未来,“双京”将持续强强联手,让更多人了解ADS Pro技术、选购ADS Pro技术赋能的高端电视,让更好的视听体验走进千家万户。
在品鉴会现场,BOE(京东方)带领行业探寻了一系列ADS Pro的技术真相:
真相一:在环境光对比度方面,通常传统液晶显示技术所呈现的对比度多数用暗室对比度进行测试,脱离用户真实使用场景的数值是毫无意义的。在真实应用场景中,室内常规照明情况下的环境光对比度对用户更有意义,也是决定用户真实体验的关键指标,BOE(京东方)创新升级环境光对比度(ACR)这一更加适配当前屏幕使用场景的测试指标,更准确地表征人眼真实感知的对比度。作为中国唯一拥有自主知识产权的高端液晶显示技术,BOE(京东方)ADS Pro技术对比度测试标准从用户体验出发,在近似真实的使用场景下进行数据测试,ACR数值高达1400:1,与其他同类技术相比具有显著优势。同时,通过像素结构优化、新材料开发、表面处理等多种减反技术,大幅降低了显示屏幕光线反射率,达到远超常规显示技术的超高环境光对比度,实现更高的光线适应性和更佳的画质表现。在BOE(京东方)ACR技术的加持下,能够让消费者在观看屏幕时无需受到环境光干扰。
真相二:在广视角方面,传统测量标准下,几乎所有产品都能达到所谓的“广视角”,但实际观看效果有很大区别,“色偏视角”才能更客观反馈广视角显示效果。大屏观看时,产品色偏问题显得尤为突出。色偏是指屏幕在侧视角观看时,亮度/色彩的变化与正视角观看时的差异,色偏视角能真实呈现色彩的“本真”。在所有显示技术中,ADS Pro在广视角观看时显示效果最能够达到真实还原,实现接近180°的超广视角,且全视角无色偏、无褪色,让消费者实现家庭观影处处都是“C位”,这也成为ADS Pro技术的另一大重要优势。
真相三:高端LCD显示技术依然是大屏电视产品的主流,并实现了媲美OLED的显示效果。基于BOE(京东方)ADS Pro技术进一步升级的高端LCD解决方案UB Cell,所呈现的完美画质可以媲美OLED,甚至超越它的显示效果,这是LCD显示技术领域发展的重要里程碑。BOE(京东方)UB Cell技术在感知画质、信赖性、能耗等方面相较于其他技术更具优势。由于采用了多种减反技术,UB Cell显示屏的表面反射率大幅降低,使其环境光对比度远高于市场旗舰机型中应用其他技术的产品,从而极大提升屏幕的亮态亮度和暗态亮度的比值,让画面显示的暗态部分更暗、亮态部分更亮,画质更加细腻逼真。同时,BOE(京东方)通过开发新型光学相位补偿技术,实现了超宽视角,使得UB Cell技术的大视角色彩表现能力进一步提升。此外,借助ADS Pro技术的特有像素结构,配合宽频液晶材料体系和驱动算法,可以全灰阶满足G-sync 标准,完全无屏闪,更护眼的同时也让显示画面更稳定更流畅。
真相四:ADS Pro广视角和高刷的优势,结合Mini LED在HDR和极致暗态画质的优异表现,让二者做到最完美的优势互补,这样的组合才是画质提升的最佳拍档!BOE(京东方)采用高光效LED+高对比度ADS Pro面板+画质增强算法方案,相比其他显示技术有更出众的表现,不仅实现更宽的亮度范围,还进一部拓展了高亮度灰阶,扩充暗场景灰阶层次感。此外,随着刷新率的不断提升,通过ADS Pro+Mini LED实现分区动态差黑,可以极大提升高速运动画面清晰度,显著减少卡顿、延迟等现象,树立电竞显示的性能画质新标杆。目前,ADS Pro+Mini LED解决方案已成为全品类产品的应用趋势。
真相五:作为目前全球领先的主流液晶显示技术,BOE(京东方)ADS Pro是唯一可以覆盖从手机、平板电脑、笔记本、显示器到电视所有产品类型的技术。ADS Pro技术在大屏上的优势更加明显,并且通过专业级高端画质、极致的视觉享受及一系列健康护眼技术,为各行业客户打开新的增长空间。目前ADS Pro技术在显示器、笔记本、平板电脑、大屏高端电视等领域市场份额逐年攀升,已成为全球各大一线终端品牌高端机型的首选。群智咨询总经理李亚琴表示,五年后,LCD面板出货面积较当前水平将达到两位数增幅。在用户需求和技术升级的双重驱动下,ADS/IPS技术在IT市场将位居绝对主流地位并逐年提升份额;尤其在电竞领域,用户对高分辨率、高刷新率、快速响应时间、高色域、更大尺寸屏幕等显示性能提升有很高的期待。
当前,显示无处不在的时代已经到来,显示技术的持续迭代升级,背后的“真像”是中国科技力量在全球发挥着越来越重要的价值。作为全球半导体显示行业龙头企业,BOE(京东方)携手行业伙伴共同发起倡议,呼吁行业各界合作伙伴打破内卷,以技术升维不断提升用户体验,从聚焦价格的“零和博弈”走向聚焦价值的“融合共生”,开辟新技术、新赛道、新模式,共同发展高科技、高效益、高质量的新质生产力!未来,以BOE(京东方)为代表的中国科技企业也将持续创新,为消费带来更真实、更智能、更时尚、更节能的显示技术和产品,引领中国屏幕走向全球,为产业高质升维发展注入源源不断的新动能。
收起阅读 »如何优雅的将MultipartFile和File互转
我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。
前言
首先来区别一下MultipartFile和File:
- MultipartFile是 Spring 框架的一部分,File是 Java 标准库的一部分。
- MultipartFile主要用于接收上传的文件,File主要用于操作系统文件。
MultipartFile转换为File
使用 transferTo
这是一种最简单的方法,使用MultipartFile自带的transferTo 方法将MultipartFile转换为File,这里通过上传表单文件,将MultipartFile转换为File格式,然后输出到特定的路径,具体写法如下。
使用 FileOutputStream
这是最常用的一种方法,使用 FileOutputStream 可以将字节写入文件。具体写法如下。
使用 Java NIO
Java NIO 提供了文件复制的方法。具体写法如下。
File装换为MultipartFile
从File转换为MultipartFile 通常在测试或模拟场景中使用,生产环境一般不这么用,这里只介绍一种最常用的方法。
使用 MockMultipartFile
在转换之前先确保引入了spring-test 依赖(以Maven举例)
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>version</version>
<scope>test</scope>
</dependency>
通过获得File文件的名称、mime类型以及内容将其转换为MultipartFile格式。具体写法如下。
更多文章干货,推荐公众号【程序员老J】
来源:juejin.cn/post/7295559402475667492
一起读本书吧~《认知觉醒》,潜意识里有你要的答案
读后感
书中改变我的话:
- 佼佼者更愿意做高耗能的事——消除模糊,制造清晰。
- 先用感性选择,再用理性思考,或许是一个更好的策略,尤其是在做哪些重大选择时。
- 针对当下的时间,保持觉知,审视第一反应,产生明确的主张;针对全天的日程,保持清醒,时刻明确下一步要做的事情;针对长远的目标,保持思考,想清楚长远意义和内在动机。
第二章书摘
- “学霸”和普通同学之间的差异不仅体现在勤奋的程度上,还体现在努力的模式上:谁更愿意做高耗能的事——消除模糊,制造清晰。
- 多数人为了逃避真正的思考,愿意做任何事。
- 记住,任何痛苦事件都不会自动消失,哪怕再小的事情也是如此。要想不受其困扰,唯一的办法就是正视他、看清它、拆解它、化解它,不给它进入潜意识的机会,不给它变模糊的机会,即使已经进入潜意识,也要想办法将它挖出来。
- 恐惧就是一个欺软怕硬的货色,你躲避它,它就张牙舞爪,你正视它,它就原形毕露。一旦把它看的清清楚楚,情绪就会慢慢从潜意识中消散,你的生活将会舒畅无比。
- 认知清晰,情绪平和,最终还要行动坚定。很多人把行动力不足的原因归结为环境干扰或是意志力弱,其实,行动力不足的真正原因是选择模糊。
- 潜意识的感性总能帮我们发现什么是真正适合自己的,从而引导精力投入,快速提升自己。
- 先用感性选择,再用理性思考,或许是一个更好的策略,尤其是在做哪些重大选择时。诚如洪兰教授的建议:小事听从你的脑,大事听从你的心。
- 梦境。梦境是潜意识传递信息的一种方式,它可能是内心最真实想法的展示,也可能是灵感的启发。
- 针对当下的时间,保持觉知,审视第一反应,产生明确的主张;针对全天的日程,保持清醒,时刻明确下一步要做的事情;针对长远的目标,保持思考,想清楚长远意义和内在动机。
针对书摘1:如此道理,当你面对不了解的事物时,唯有抽丝剥茧、不断细化,并且直面它,你才能清晰的了解到事物的全貌,否则就是管中窥豹,同样,这也是消除焦虑的最好方式。
针对书摘2:无论多么庞大的任务,最怕的就是任务分解,分解它的过程也是你直面和了解它的过程,在拆解的过程中你将对它越来越清晰,在此过程中,会消除你因为对其不了解而产生的焦虑,同样也是让自己的工作具象化、透明化的过程。
针对书摘5:你迟迟不肯行动,也许是担心自己做不好(低期望值),或许是觉得这件事没有意义(价值感不足),也可能是自己每次在行动时总三心二意(高冲动),也可能最终日期很遥远,当下没有一定要做这件事的压力,但是无论什么原因,都请把握这件事自己可以把握的部分,让事情本身对自己变得有意义、有助于自己成长,做自己当下应该做的事。
针对书摘6:兴趣是最好的老师,在前行的路上保持好奇心。
针对书摘7:潜意识往往不会骗自己,且更符合自己的内心最真实的想法,首先选择你喜欢的,选择后再经过自己理性的思考,得到最终结果。
针对书摘8:明确当下的任务(当下目标),保持清晰的思路做事(短期目标),坚持长期人生主义。
每一次克制自己,就意味着比以前更强大。——高尔基
来源:juejin.cn/post/7430801077455798299
微信的消息订阅,就是小程序有通知,可以直接发到你的微信上
给客户做了一个信息发布的小程序,今天客户提要求说希望用户发布信息了以后,他能收到信息,然后即时给用户审核,并且要免费,我就想到了微信的订阅消息。之前做过一次,但是忘了,这次记录一下,还是有一些坑的。
一 先申请消息模版
先去微信公众平台,申请消息模版
在uni-app 里面下载这个插件uni-subscribemsg
我的原则就是有插件用插件,别自己造轮子。而且这个插件文档很好
根据文档定义一个message.js 云函数
这个其实文档里面都有现成的代码,但我还是贴一下自己的吧。
'use strict';
const uidObj = require('uni-id');
const {
Controller
} = require('uni-cloud-router');
// 引入uni-subscribemsg公共模块
const UniSubscribemsg = require('uni-subscribemsg');
// 初始化实例
let uniSubscribemsg = new UniSubscribemsg({
dcloudAppid: "填你的应用id",
provider: "weixin-mp",
});
module.exports = class messagesController extends Controller {
// 发送消息
async send() {
let response = { code: 1, msg: '发送消息失败', datas: {} };
const {
openid,
data,
} = this.ctx.data;
// 发送订阅消息
let resdata = await uniSubscribemsg.sendSubscribeMessage({
touser: openid,// 就是用户的微信id,决定发给他
template_id: "填你刚刚申请的消息模版id",
page: "pages/tabbar/home", // 小程序页面地址
miniprogram_state: "developer", // 跳转小程序类型:developer为开发版;trial为体验版;formal为正式版;默认为正式版
lang: "zh_CN",
data: {
thing1: {
value: "信息审核通知"// 消息标题
},
thing2: {
value: '你有新的内容需要审核' // 消息内容
},
number3: {
value: 1 // 未读数量
},
thing4: {
value: '管理员' // 发送人
},
time7: {
value: data.time // 发送时间
}
}
});
response.code = 0;
response.msg = '发送消息成功';
response.datas = resdata;
return response;
}
}
四 让用户主动订阅消息
微信为了防止打扰用户,需要用户订阅消息,并且每次订阅只能发送一次,不过我取巧,在用户操作按钮上偷偷加订阅方法,让用户一直订阅,我就可以一直发
// 订阅
dingYue() {
uni.requestSubscribeMessage({
tmplIds: ["消息模版id"], // 改成你的小程序订阅消息模板id
success: (res) => {
if (res['消息模版id'] == 'accept') {
}
}
});
},
五 讲一下坑
我安装了那个uni-app 的消息插件,但是一直报错找不到那个模块。原来是unicloud 云函数要主动关联公共模块,什么意思呢,直接上图。
来源:juejin.cn/post/7430353222685048859
mysql到底是join性能好,还是in一下更快呢?
大家好呀,我是楼仔。
今天发现一篇很有意思的文章,使用 mysql 查询时,是使用 join 好,还是直接 in 更好,这个大家工作时经常遇到。
为了方便大家查看,文章我重新进行了排版。
我没有直接用作者的结论,感觉可能会误导读者,而是根据实验结果,给出我自己的建议。
不 BB,上目录:
01 背景
事情是这样的,去年入职的新公司,之后在代码 review 的时候被提出说,不要写 join,join 耗性能还是慢来着,当时也是真的没有多想,那就写 in 好了。
最近发现 in 的数据量过大的时候会导致 sql 慢,甚至 sql 太长,直接报错了。
这次来浅究一下,到底是 in 好还是 join 好,仅目前认知探寻,有不对之处欢迎指正。
以下实验仅在本机电脑试验。
02 表结构
2.1 用户表
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '姓名',
`gender` smallint DEFAULT NULL COMMENT '性别',
`mobile` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `mobile` (`mobile`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1005 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
2.2 订单表
CREATE TABLE `order` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`price` decimal(18,2) NOT NULL,
`user_id` int NOT NULL,
`product_id` int NOT NULL,
`status` smallint NOT NULL DEFAULT '0' COMMENT '订单状态',
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `product_id` (`product_id`)
) ENGINE=InnoDB AUTO_INCREMENT=202 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
03 千条数据情况
数据量:用户表插一千条随机生成的数据,订单表插一百条随机数据
要求:查下所有的订单以及订单对应的用户
耗时衡量指标:多表连接查询成本 = 一次驱动表成本 + 从驱动表查出的记录数 * 一次被驱动表的成本
3.1 join
select order.id, price, user.name from order join user on order.user_id = user.id;
3.2 in
select id,price,user_id from order;
select name from user where id in (8, 11, 20, 32, 49, 58, 64, 67, 97, 105, 113, 118, 129, 173, 179, 181, 210, 213, 215, 216, 224, 243, 244, 251, 280, 309, 319, 321, 336, 342, 344, 349, 353, 358, 363, 367, 374, 377, 380, 417, 418, 420, 435, 447, 449, 452, 454, 459, 461, 472, 480, 487, 498, 499, 515, 525, 525, 531, 564, 566, 580, 584, 586, 592, 595, 610, 633, 635, 640, 652, 658, 668, 674, 685, 687, 701, 718, 720, 733, 739, 745, 751, 758, 770, 771, 780, 806, 834, 841, 856, 856, 857, 858, 882, 934, 942, 983, 989, 994, 995);
其中 in 的是order查出来的所有用户 id。
如此看来,分开查和 join 查的成本并没有相差许多。
3.3 并发场景
主要用php原生写了脚本,用ab进行10个同时的请求,看下时间,进行比较。
> ab -n 100 -c 10 // 执行脚本
下面是 join 查询的执行脚本:
$mysqli = new mysqli('127.0.0.1', 'root', 'root', 'test');
if ($mysqli->connect_error) {
die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
}
$result = $mysqli->query('select order.id, price, user.`name` from `order` join user on order.user_id = user.id;');
$orders = $result->fetch_all(MYSQLI_ASSOC);
var_dump($orders);
$mysqli->close();
下面是 in 查询的执行脚本:
$mysqli = new mysqli('127.0.0.1', 'root', 'root', 'test');
if ($mysqli->connect_error) {
die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
}
$result = $mysqli->query('select `id`,price,user_id from `order`');
$orders = $result->fetch_all(MYSQLI_ASSOC);
$userIds = implode(',', array_column($orders, 'user_id')); // 获取订单中的用户id
$result = $mysqli->query("select `id`,`name` from `user` where id in ({$userIds})");
$users = $result->fetch_all(MYSQLI_ASSOC);// 获取这些用户的姓名
// 将id做数组键
$userRes = [];
foreach ($users as $user) {
$userRes[$user['id']] = $user['name'];
}
$res = [];
// 整合数据
foreach ($orders as $order) {
$current = [];
$current['id'] = $order['id'];
$current['price'] = $order['price'];
$current['name'] = $userRes[$order['user_id']] ?: '';
$res[] = $current;
}
var_dump($res);
// 关闭mysql连接
$mysqli->close();
看时间的话,明显 join 更快一些。
04 万条数据情况
user表现在10000条数据,order表10000条试下。
4.1 join
4.2 in
order 耗时:
user 耗时:
4.3 并发场景
join 耗时:
in 耗时:
数据量达到万级别,非并发场景,in 更快,并发场景 join 更快。
05 十万条数据情况
随机插入后user表十万条数据,order表一百万条试下。
5.1 join
5.2 in
order 耗时:
user 耗时:
order查出来的结果过长了...
5.3 并发场景
join 耗时:
in 耗时:
数据量达到十万/百万级别,非并发场景,in 过长,并发场景 join 更快。
06 总结
实验结论:
- 数据量不到万级别,join 和 in 差不多;
- 数据量达到万级别,非并发场景,in 更快,并发场景 join 更快;
- 数据量达到十万/百万级别,非并发场景,in 过长,并发场景 join 更快。
下面是楼仔给出的一些建议。
当数据量比较小时,建议用 in,虽然两者的性能差不多,但是 join 会增加 sql 的复杂度,后续再变更,会非常麻烦。
当数据量比较大时,建议用 join,主要还是出于查询性能的考虑。
不过使用 join 时,小表驱动大表,一定要建立索引,join 的表最好不要超过 3 个,否则性能会非常差,还会大大增加 sql 的复杂度,非常不利于后续功能扩展。
最后,把楼仔的座右铭送给你:我从清晨走过,也拥抱夜晚的星辰,人生没有捷径,你我皆平凡,你好,陌生人,一起共勉。
原创好文:
来源:juejin.cn/post/7306322677039218724
自研一套带双向认证的Android通用网络库
当前,许多网络库基于Retrofit或OkHttp开发,但实际项目中常需要定制化,并且需要添加类似双向认证等安全功能。这意味着每个项目都可能需要二次开发。那么,有没有一种通用的封装方式,可以满足大多数项目需求?本文将介绍一种通用化的封装方法,帮助你用最少的代码量开发出自己的网络库。
源码及涉及思路参考:如何开发一款安全高效的Android网络库(详细教程)
框架简介
FlexNet 网络库是基于 Square 公司开源的 Retrofit 网络框架进行封装的。Retrofit 底层采用 OkHttp 实现,但相比于 OkHttp,Retrofit 更加便捷易用,尤其适合用于 RESTful API 格式的请求。
在网络库内部,我们实现了双向认证功能。在初始化时,您可以选择是否开启双向认证,框架会自动切换相应的 URL,而业务方无需关注与服务端认证的具体细节。
接入方式
1. 本地aar依赖
下载aar到本地(下载地址见文末),copy到app的libs目录下,如图:
implementation(files("libs/flex-net.aar"))
然后sync只会即可
2. 通过Maven远程依赖
FlexNet目前已上传Maven,可通过Maven的方式引入,在app的build.gradle中加入以下依赖:
implementation("com.max.android:flex-net:3.0.0")
sync之后即可拉到Flex-Net
快速上手
网络库中默认打开了双向认证,并根据双向认证开关配置了相应的 baseUrl,大多数场景下只需要控制双向认证开关,其余配置走默认即可。
初始化
在发起网络请求之前(建议在Application
的onCreate()
中),调用:
fun initialize(
app: Application,
logEnable: Boolean = BuildConfig.LOG_DEBUG,
sslParams: SSLParams? = null,
)
- application: Application类型,传入当前App的Application实例;
- logEnable: Boolean类型,网络日志开关,会发打印Http的Request和Resonpse信息,可能涉及敏感数据,release包慎用;(仅限网络请求日志,和双向认证的日志不同)
- sslParams: 双向认证相关参数,可选,为空则关闭双向认证。具体描述见下文。
当App需要双向认证功能时,需要在initialize()
方法中传递sslParams参数,所有双向认证相关的参数都放在sslParams当中,传此参数默认打开双向认证。
SSLParams的定义如下:
data class SSLParams(
/** App 是否在白名单之中。默认不在 */
val inWhiteList: Boolean = false,
/** 双向认证日志开关,可能涉及隐私,release版本慎开。默认关 */
val logSwitch: Boolean = true,
/** 是否开启双向认证。默认开 */
val enable: Boolean = true,
/** 双向认证回调。默认null */
val callback: MutualAuthCallback = null,
)
- inWhiteList: App是否在白名单中,默认不在
- logSwitch: 双向认证日志开关,可能涉及隐私,release版本慎开。默认关,注意这里仅针对双向认证日志,与
initialize()
方法中的logEnable
不同 - callback : 监听初始化结果回调,true表示成功,反之失败。可选参数,默认为null,仅
enableMutualAuth
为true时有效
在调用了initialize
之后就完成了初始化工作,内部包含了双向认证、网络状态、本地网络缓存等等功能,所有的网络请求都需要在初始化之后发起。
初始化示例代码:
FlexNetManger.initialize(this,
logEnable = true,
SSLParams {
Timber.i("Mutual auth result : $it")
})
PS *: *部分App在启动的时候获取不到证书,所以这里会失败。如果失败了后续可以在合适的时机通过MutualAuthenticate.isSSLReady()
来检查是否认证成功,然后通过MutualAuthenticate.suspendBuildSSL()
来主动触发双向认证,成功之后方可开始网络请求。具体可参见文档“配置项”的内容。
双向认证失败及其相关问题,可参考双向认证文档 : [双向认证])
定义数据 Model
在请求之前需要根据接口协议的字段定义对应的数据Model,用来做Request或者Response的body。
比如我们需要通过UserId获取对应用户的UserName
定义 Request 数据 Model
后端请求接口参数如下:
{
"userId" : "123456"
}
那么根据参数定义一个UserNameReq类:
data class UserNameReq(
/** 用户id */
var userId: String
)
定义 Response 数据 Model
后端返回数据如下:
{
"userName" : "MC"
}
对应定义一个UserNameRsp:
data class UserNameRsp(
/** 用户id */
var userId: String
)
编写 Http 接口
接口类必须继承自IServerAPI:
interface UserApi: IServerApi
然后在IServerApi的实现类中,每个接口需要用注解的形式标注 Http 方法,通过参数传入 http 请求的 url:
interface UserApi: IServerApi {
/** 获取用户ID */
@POST("api/cloudxcar/atmos/v1/getName")
suspend fun getUserName(@Body request: UserNameReq): ResponseEntity
}
这里需要注意的是,我们的UserNameRsp需要用ResponseEntity封装一层,看一下ResponseEntity的内容:
sealed class ResponseEntity<T>(val body: T?, val code: Int, val msg: String)
有3个参数:
- body: 消息体,即UserNameReq。仅成功时有效
- code : 返回码,这里要分多种情况描述。
- Http错误:此时code为Http错误码
- 其他异常:code对应错误原因,后面会附上映射表
- 请求成功:区分网络数据和缓存数据
- msg : 错误信息
可调用ResponseEntity.isSuccessful()
来判断是否请求成功,然后通过ResponseEntity.body
获取数据,返回的是一个根据服务端返回的 Json 解析而来的UserNameRsp实体类。
如果请求失败,则从ResponseEntity.msg
和ResponseEntity.code
中获取失败ma失败码和失败提示
创建网络请求Repo
继承自BaseRepo,泛型参数为步骤3中创建的IserverApi实现类:
class VersionRepo : BaseRepo<VersionAPI>
- 其中需要有1个必覆写的变量:
- baseUrl: 网络接口的baseUrl
- 两个可选项:
- mutualAuthSwitch: 双向认证开关,此开关仅针对当前 baseUrl 生效。默认开
- interceptorList: 需要设置的拦截器列表
- 一个必覆写的方法:
- createRepository(): 创建当前网络仓库
完整的Repo类内容如下:
class UserRepo: BaseRepo<UserApi>() {
// 必填
override val baseUrl = "https://juejin.cn/editor/drafts/7379502040140218422"
// 必填
override fun createRepository(): VersionAPI =
MutualAuthenticate.getServerApi(baseUrl, mutualAuthSwitch, interceptorList)
// 可选:双向认证开关,仅针对当前repo生效
override val mutualAuthSwitch = true
// 可选:Http拦截器
override val interceptorList: List? = listOf(HeaderInterceptor())
// 请求接口
suspend fun getUserName(): ResponseEntity{
return mRepo.upgradeVersion(UserNameReq("123456"))
}
}
注: 其中拦截器的设置interceptorList,如果声明的时候提示错误,可以尝试加上完整的类型声明:
interceptorList: List?
5 发起网络请求
最后就可以在业务代码中通过Repo类完成网络请求的调用了:
lifecycleScope.launch {
val entity= UserRepo().getUserName()
Timber.i("Get responseEntity: $entity")
if (entity.isSuccessful()) {
val result = entity.body
Timber.i("Get user name result: $result")
} else {
val code = entity.code
val msg = entity.msg
Timber.i("Get user name failed: code->$code; msg->$msg")
}
}
到这里,就可以发起一次基础的网络请求接口了。
依赖项
双向认证
目前引入的双向认证版本为1.6.0,如果需要切换版本,或者编译出现依赖冲突,可以尝试使用exclude的方式自行依赖。当然也请自行确保功能正常。
日志库
implementation("com.jakewharton.timber:timber:4.7.0")
组件库中的日志库。FlexNet推荐宿主使用Timber进行日志输出,但是需要宿主App在初始化FlexNet之前对Timber做plant操作。
网络请求内核
// Net
implementation ("com.squareup.retrofit2:retrofit:2.9.0")
implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
底层网络请求目前依赖OkHttp完成。
本地持久化
implementation("com.tencent:mmkv:1.2.14")
网络库中的本地存储,主要用于保存网络缓存,目前采用MMKV-1.2.14版本,同样如果有冲突,或者需要另换版本,可通过exclude实现。
Gson
api(core.network.retrofit.gson) {
exclude(module = "okio")
exclude(module = "okhttp")
}
依赖Gson,用于做数据结构和Json的相互转化
错误码对照表
CODE_SUCCESS | 10000 | 请求成功,数据来源网络 |
---|---|---|
CODE_SUCCESS_CACHE | 10001 | 返回成功,数据来源于本地缓存 |
CODE_SUCCESS_BODY_NULL | 10002 | 请求成功,但消息体为空 |
CODE_ERROR_UNKNOWN | -200 | 未知错误 |
CODE_ERROR_UNKNOWN_HOST | -201 | host解析失败,无网络也属于其中 |
CODE_ERROR_NO_NETWORK | -202 | 无网络 |
日志管理
从FlexNet 2.0.5开始,对接入方使用的日志库不再限制(2.0.5以下必须用Timber,否则无日志输出)。可以通过以下接口来设置日志监视器:
setLogMonitor(log: ILog)
设置之后所有的网络日志都会回调给ILog,即可由接入方自行决定如何处理日志数据。
如果没有设置LogMonitor
,则会使用Timber
或者Android
原生Log
来进行日志输出。当宿主App的Timber挂载优先于FlexNet的初始化,则会采用Timber做日志输出,反之使用Android Log。
文件下载
网络库内置了下载功能,可配置下载链接和下载目录。注意外部存储地址需要自行申请系统权限。
1 构建下载器
使用Downloader.builder()
来构建你的下载器,Builder需要传入以下参数:
- url:待下载文件的url
- filePath:下载文件路径
- listener:下载状态回调。可选参数,空则无回调
示例代码如下:
Downloader.Builder("https://juejin.cn/editor/drafts/7379502040140218422.zip",
File(requireContext().filesDir, "MC").absolutePath)
2 回调监听
builder()
最后一个参数,可传入下载监听器接口DownloadListener
,内部有3个方法需要实现:
- onFinish(file: File): 下载完成,返回下载完成的文件对象
- onProgress( progress : Int, downloadedLengthKb: Long, totalLengthKb: Long): 下载进度回调,回传进度百分比、已下载的大小、总大小
- onFailed(errMsg: String?): 下载失败,回调失败信息
示例代码如下:
val downloader = Downloader.Builder("https://juejin.cn/editor/drafts/7379502040140218422.zip",
File(Environment.getExternalStorageDirectory(), "MC").absolutePath,
object : DownloadListener {
override fun onFinish(file: File) {
Timber.e("下载的文件地址为:${file.absolutePath}".trimIndent())
}
override fun onProgress(
progress: Int,
downloadedLengthKb: Long,
totalLengthKb: Long,
) {
runOnUiThread {
textView.text =
"文件文件下载进度:${progress}% \n\n已下载:%${downloadedLengthKb}KB | 总长:${totalLengthKb}KB"
}
}
override fun onFailed(errMsg: String?) {
Timber.e("Download Failed: $errMsg")
}
}).build()
PS : 这里要注意,FlexNet会在业务方调用下载的线程返回下载回调,所以绝大部分时候回调是发生在子线程,此时如果有线程敏感的功能(比如刷新UI),需要自行处理线程切换。
3 触发下载
通过Builder.build()
创建 Downloader 下载器,最后调用Downloader.download()
方法即可开始下载。
和Http Request一样,download()
是一个suspend方法,需要在协程中使用:
lifecycleScope.launch(Dispatchers.IO) {
downloader.download()
}
整体架构
设置配置项
1. 设置双向认证开关
在初始化的时候控制双向认证开关:
fun init(context: Application, needMutualAuth: Boolean = true)
方法内部会根据开关值来切换不同的后端服务器,但是有些App不能过早的获取证书,这样会有双向认证失败的风险,FlexNet同时支持懒汉式的主动双向认证
2. 主动双向认证接口
在确定拿到证书,或者确定可以双向认证的时机,可随时发起双向认证请求:
MutualAuthenticate.suspendBuildSSL()
可通过
MutualAuthenticate.isSSLReady()
接口来检查当前双向认证是否成功。
主动触发示例代码如下:
MutualAuthenticate.suspendBuildSSL {
if (it) {
Toast.makeText(context, "双向认证成功,可以开始访问加密资源", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "双向认证失败", Toast.LENGTH_SHORT).show()
}
}
3. 数据缓存
在前面发起请求调用httpRequest
顶层函数的时候,可以传入一个可选参数cacheKey
,这个key不为空则网络库会在本地保存当前请求的返回数据。Key作为缓存的唯一标识,在无网络或请求失败的时候,会通知调用方错误,并返回缓存的数据。
缓存部分流程如下:
4. 错误及异常处理
在发起请求的顶层函数 httpRequest
中,有两个参数用来提供给调用方处理错误和异常。
首先区分一下错误和异常:
错误通常是发起了网络请求,且网络请求有响应,只是由于接口地址或者参数等等原因导致服务端解析失败,最终返回错误码及错误信息。
而异常是指在发起网络请求的过程中出现了 Exception,导致整个网络请求流程被中断,所以当异常发生的时候,网络库是不会返回错误码和错误信息的,只能返回异常信息供调用方定位问题。
回调的使用方式很简单,只需要在httpRequest
中传入两个回调:fail
和error
,下面分别看看二者的处理方式:
1. 错误处理
fai的定义如下:
fail: (response: ResponseEntity) -> Unit = {
onFail(it)
}
传入的回调有一个 ResponseEntity 参数,这是网络请求返回的响应实体,内部包含errorCode
和errorMessage
,不传则默认打印这两个字段,可以在 Logcat 中通过Tag:Http Request
**过滤出来。
2. 异常处理
error的定义如下:
error: (e: Exception) -> Unit = {
onError(it)
} ,
回调函数只有一个 Exeption 对象,和前面的定义相符,在异常的时候将异常返回供调用方定位问题。不传网络库默认打印异常,可以在 Logcat 中通过Tag:Http Request
**过滤出来。
扩展接口:发起请求并处理返回结果
网络库定义了一个顶层函数用来发起请求并接收返回结果或处理异常:
fun
- block: 实际请求体,必填。可以传入步骤 4 中实现的接口
- fail: 请求错误回调,非必填。用来处理服务端返回的请求错误,会携带错误码及错误信息
- error: 请求异常回调,非必填。用来处理请求中发生的异常,此时没有response返回
- cacheKey: 数据缓存唯一标识,非必填
httpRequest 中的泛型 T 就是接入步骤2定义的 Response 实体,正常返回会在方法内部自动解析出 UserNameRsp
,到此就完成了一次网络请求。
以上是基本的使用方式,涵盖了安全、数据请求、缓存、异常处理等功能,可以适应于多种项目场景。应大家的建议,后续会完善几篇文章拆解具体的原理及开发思路,从源码的角度教你如何从0开发一套完善的网络库
大家如果想了解设计思路及框架原理,可以参考:源码及涉及思路参考:如何开发一款安全高效的Android网络库(详细教程)
需要体验的同学可以在评论区留下联系方式,我给你发送aar以及源码。有问题欢迎随时探讨
来源:juejin.cn/post/7379521155286941708