注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

现在小厂实习都问的这么难了吗😱「万物心选一面(北京+电商)(vue+小程序)」

h5是怎么和微信小程序通信的?不同H5页面怎么通信的?webview是谁提供的?web-view 是往小程序中嵌入h5的容器。会自动铺满整个小程序页面。微信小程序和 uni-app 都提供了 web-view 组件。由于简历上有一...
继续阅读 »

h5是怎么和微信小程序通信的?不同H5页面怎么通信的?webview是谁提供的?

  1. web-view 是往小程序中嵌入h5的容器。会自动铺满整个小程序页面。微信小程序和 uni-app 都提供了 web-view 组件。
  2. 由于简历上有一段实习是移动端h5开发,让面试官误以为我做过微信小程序的内嵌h5页面,但其实实习中都是公司框架写的代码,页面也是直接嵌入公司的app中的,我并没有微信小程序开发的经验,现在整理答案也是死记硬背,mark一下看到的一篇讲的比较清楚的文章,以后学小程序了再来回顾。
    微信小程序web-view与H5 通信方式探索 - 掘金 (juejin.cn)
  3. h5 页面间通信其实就是前端跨页面通信(吧?)当时第一反应回答的是使用LocalStorage, 面试官又提出用户修改个人信息后返回页面更新信息的情况,回答的是我之前做表单有类似的场景,是向后端提交后在后端更新了数据,回退到原先的页面的时候在 created/activated 的时候获取数据。
    面试官:前端跨页面通信,你知道哪些方法? - 掘金 (juejin.cn)

什么是微任务?什么是宏任务?


如果说哪些操作是宏任务,哪些操作是微任务,那大部分同学都是比较清楚的:

  • 宏任务:script(整体代码)/setTimout/setInterval/setImmediate(node 独有)/requestAnimationFrame(浏览器独有)/IO/UI render(浏览器独有)
  • 微任务:process.nextTick(node 独有)/Promise.then()/Object.observe/MutationObserver

这里面试官还追问了一句,Promise本身是微任务吗


那这两者有具体的定义吗?老规矩,直接 mdn 开搜。mdn 中可以找到微任务(microtask),但是并没有宏任务或者(macrotask)的信息。但是在在 JavaScript 中通过 queueMicrotask() 使用微任务 - Web API 接口参考 | MDN 中我们可以发现,在文档中只有taskmicrotask,对应的就是事件循环中的任务队列task queue和微任务队列microtask queue


文档中还提到了JavaScript 中的 promise 和 Mutation Observer API 都使用微任务队列去运行它们的回调函数,想来面试官的意思就是,Promise本身只是一个代理,Promise()是他的构造函数,真正被放进微任务队列的是Promise的then方法中的回调函数。


文档中对任务和微任务的定义也比较冗长,我想能区分哪些是微任务,哪些是宏任务,说出他们分别会被放在任务队列和微任务队列以及他们的执行顺序(事件循环会持续调用微任务直至队列中没有留存的,再去调用任务队列)应该足够面试了。




遍历对象有哪些方法,如果是对象的原型链上的属性会不会被遍历到?有什么办法可以排除原型链上的属性?


直接上代码测试一波:

Object.prototype.age=18;   // 修改Object.prototype  
const person ={ name: "小明" };

// 输出 name, age
for(key in person){
console.log(key)
}

// 输出 name
Object.keys(person).forEach(key=>{
console.log(key);
})

// 输出 小明, 18
for(key in person){
console.log(person[key])
}

// 输出 小明
Object.values(person).forEach(value=>{
console.log(value);
})

// 输出 name: 小明
for (const [key, value] of Object.entries(person)) {
console.log(`${key}: ${value}`);
}

很明显,for...in 会遍历到原型链上的属性,Object上的keysvaluesentires方法不会。
看看 mdn 怎么说:



for...in 语句以任意顺序迭代一个对象的除Symbol以外的可枚举属性,包括继承的可枚举属性




Object.keys()  静态方法返回一个由给定对象 自身 的可枚举的字符串键属性名组成的数组。
Object.entries()  静态方法返回一个数组,包含给定对象 自有 的可枚举字符串键属性的键值对。


Object.values()  静态方法返回一个给定对象的 自有 可枚举字符串键属性值组成的数组。



那么如果仍然想使用 for...in 来遍历对象,并且不想要原型链上的属性,我们可以使用 Object.hasOwn 过滤掉它们:

for (key in person) {
if (person.hasOwn(key)) {
console.log(key);
}
}


如果指定的对象自身有指定的属性,则静态方法 Object.hasOwn()  返回 true。如果属性是继承的或者不存在,该方法返回 false



组件通信有哪些方法?依赖注入的数据是不是响应式的?有什么办法让他保持响应式的?

  • props / $emit
  • $emit / $on (eventBus)
  • provide / inject
  • $attrs / $listeners
  • ref / $ref
  • $parent / $children
  • vuex / pinia

Vue 组件间通信六种方式(完整版) - 掘金 (juejin.cn)


vue更新dom是异步还是同步?如何不使用nexttick实现nexttick的功能?vue的更新是哪一种微任务?


Vue更新DOM是异步的。这意味着我们在修改完data之后并不能立刻获取修改后的DOM元素。Vue需要通过nextTick方法才能获取最新的DOM。


Vue在调用Watcher更新视图时,并不会直接进行更新,而是把需要更新的Watcher加入到queueWatcher队列里,然后把具体的更新方法flushSchedulerQueue传给nextTick进行调用。nextTick只是单纯通过Promise、setTimeout等方法模拟的异步任务。


如果你想要不使用nextTick实现nextTick的功能,你可以使用Promise、setTimeout等方法来模拟异步任务。例如,你可以使用 Promise.resolve().then(callback) 或者 setTimeout(callback, 0) 来实现类似于nextTick的功能。


至于Vue的更新是哪一种微任务,它取决于浏览器兼容性。Vue会根据浏览器兼容性,选用不同的异步策略。例如,如果浏览器兼容Promise,那么Vue就会使用Promise来实现异步更新。如果浏览器不兼容Promise但兼容MutationObserver,那么Vue就会使用MutationObserver来实现异步更新。如果浏览器既不兼容Promise也不兼容MutationObserver,那么Vue就会使用setImmediatesetTimeout来实现异步更新。


vue能监听到数组的push方法吗?直接给响应式变量赋值一个新的数组会被监听到吗?


这里讨论的都是vue2vue3当中这些问题都已经被proxy解决了。



Vue 将被侦听的数组的变更方法进行了包裹,所以它们也将会触发视图更新。这些被包裹过的方法包括:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()


替换整个对象或数组就和操作其他类型的响应式数据没区别了,自然是可以检测到的。


深入响应式原理 — Vue.js


如果要把数组api,比如push pop这些都改成async、await的异步函数要怎么做?怎么拿到这些方法?怎么传参?


这一问是我当时最蒙的,我到现在都不确定我是否领悟对了他要问什么,大致上的理解如下,如果有大佬知道的可以在评论区教学一下我这个小菜鸟。

// 保存数组原型上的push方法
const originalPush = Array.prototype.push;
// 重写数组原型上的push方法
Array.prototype.push = async function (...args) {
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 500));
return originalPush.apply(this, args);
}
async function test() {
const arr = [1, 2, 3];
await arr.push(4);
console.log(arr);
}
test(); // [1, 2, 3, 4],需要延迟定时器中设定的时间才能打印出来

在沸点一位大佬的提醒下,面试官可能想问的是这个JavaScript 异步数组 - 个人文章 - SegmentFault 思否


总结


总结就是一个字,菜。

虽然是问的有点细致,但基本上都只能回答上来每一问的第一问,后面的深入追问就懵逼了。原因是因为自己基本上都是直接对着面经和八股文准备的,没有实践过,也没有看过相关的文档。之后还是要坚持把JavaScript高级程序设计和vue设计与实现啃完,不说把这些问题记得滚瓜烂熟能对答如流,起码也要在在面试官引导下应该有思路。


作者:炫饭第一名
链接:https://juejin.cn/post/7275141653420425277
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

他扔出一张对话截图:王总说的

其实,我还在纠结,到底是写,还是不写。 也罢,给大家留作一个茶余饭后的谈资吧。如有雷同,纯属胡扯。 我在一家公司从事AI开发。我的身份是一个无法再底层的一线小开发。 产品总监又在群里贴出了那张对话截图。那是产研中心的王总跟他的对话,大体意思是王总要他组织一个同...
继续阅读 »

其实,我还在纠结,到底是写,还是不写。


也罢,给大家留作一个茶余饭后的谈资吧。如有雷同,纯属胡扯。


我在一家公司从事AI开发。我的身份是一个无法再底层的一线小开发。


产品总监又在群里贴出了那张对话截图。那是产研中心的王总跟他的对话,大体意思是王总要他组织一个同合作方的会议。


这个产品总监在这个群里,已经是第三次贴出这张截图了


第一次,他说要开一个公司层面的会。总监们纷纷发问:意义何在?


产品总监扔出这张截图:王总说的。


第二次,产品部的兄弟们有疑惑:开这个会的目的是什么?


产品总监第二次扔出这张截图:王总说的。


第三次,产品总监让我主讲这个会议。


我愣了:开会的目的是什么?


产品总监第三次扔出这张截图,表示:王总说的。


我说:不管哪个总说的,我都感觉意义不大。我可预料到过程,我说你们真好,对方说感谢夸奖,然后结束会议。


这个事情,还得从我的工作说起。我不仅从事AI项目的自研工作,同时也参与一些同第三方厂商的对接工作。因为公司的AI开发就我一个人。有些基础的AI能力,我们就打算买入第三方的服务。


因此,我的领导就安排我做调研工作。我先是调研国内AI行业的巨头。再调研垂直领域头部企业的AI开放能力。调研结束后,我将结果汇总,形成分析报告。然后,通过会议的形式,我把结果给直接领导讲了一遍。后来,直接领导拉来组内的产品,我又原样讲了第2遍。再后来,我的直接领导预约了他的直接领导,也就是我的二级领导,我又原样讲了第3遍。再后来,二级领导拉来公司所有的产品,我又原样讲了第4遍


再后来,已经不知是哪级领导,预约了给王总汇报,又安排我再原样讲第5遍。王总打断了我,他说,我不想知道你调研了多少家,以及哪家好哪家差,你辛苦那是你的工作,我不care这个。我想问,你的规划是什么?成本是多少?你将应用到哪些场景?能解决什么问题?创造多少收益?


同志们,记着我开头的声明,我是一个底层小员工。我最终还是没能回答上来。


会议结束之后,我和直接领导建议,是不是先让产品梳理一下我们到底有哪些AI需求。直接领导觉得,应该分两方面。第一,我们技术先自己想想,先按自己的想法走,这条路快。第二,慢慢地渗透给产品,让他们梳理一下,到底哪些场景会用到哪些AI能力,这条路可能要慢一些。


后来,王总主动安排下来一项任务。王总找到一家垂直行业的AI能力平台,想知道我们能不能用,好不好用。


最终这事,还是落到我的头上。我就把清单上的每一个接口,都做了调用和解析,并且采用可视化的形式来呈现结果。


我将结果给直接领导汇报了一次。结果就是,这次王总找的厂商,确实不错,带有行业加持,效果比之前我们找的都要好。直接领导找来产品总监和二级领导,我又原样讲了一遍


我的直接领导这次很机智,他想让产品梳理一下我们的产品,到底哪些地方会用到哪些AI能力。语音的能力要不要用?图像的能力具体怎么用?以便于我们技术可以进一步分析这些能力,到底能不能为我们所用。


再后来,就出现开头那一幕,产品总监安排我,同合作厂商再讲第3遍我的分析报告。并且他再次声明,那是王总安排的。


于是我就回复道:



不管哪个总安排的,这个会议意义不大。我只能说,你们的接口确实不错,他们也只能回复感谢支持。然后,尴尬结束。



因为,我们的产品规划,到底哪里用AI,用哪些AI,现在还是个空。


产品总监听到这里,很生气。他连发3条消息:



第一:到底他们的接口符不符合我们的业务场景,不符合要让他们整改,让它们攻克


第二:这绝不是一个你好我好的过场会


第三:请知悉。



群里,安静了一会儿。


我说。好吧,那我就以我自己梳理的往上靠吧。


会上,依然是我主讲。我又把已经讲了2遍的内容,讲了第3遍。我把他们每一个接口都做了分析,我表示这比之前调研的接口,效果都要好。这确实也是事实。


但是,具体我们能用吗?确实得先有产品规划,我才能确定是否能用到。为了避免成为“你好我好的过场会”,对于他们无法实现的,我提出质疑,他们说下一个版本会改好。


我马上记录下来,并确认:咱们下一个版本会改好的,对吧?


说完这句话,我收到一条钉钉消息。


产品总监发来的:来自王总的提醒,并不是我们给他们钱了,还没合作呢,不要有质问的语气!


我一看,好家伙,王总也参会了。一般提前预约都约不到的王总,居然悄悄参会了


我讲完了。王总发言说:我是中途赶来的。我想说,咱们这批接口是真的很好。你们接口的开放,是行业之大幸,对推动行业振兴很有帮助。对方说,王总太客气了,通过和咱们的交流,我们也有很大收获,也学到了很多知识。


我心想:这不还是一个你好我好的过场会。


会议结束了。


过了一会儿,我领导的领导打来电话,询问了会议的情况。最主要还是我群里发的那条:不管哪个总说的,对我来说,这个会意义不大


领导安慰我说,我估计你也是话赶话,赶到那里了。我断定你没有什么坏心眼,也不会故意使坏


我当时懵了一下。不知道他们领导层之间,到底是谁把什么消息,传播成了什么,上升到了什么层面。无职业素养地蔑视领导?以道德败坏形式破坏战略合作?王总的紧急出现,到底是巧合还是听到了什么风声?


不过,这些都无所谓,我只是一个底层小职员。我的职场生命力是最强的。我去80%的公司都可以再次成为一个小职员。


纵观整个过程。我们发现,一个企业的中层管理对企业起着至关重要的作用


第一次会议,王总不关注调研细节,这是没问题的。一个老总如果关心员工是如何进行调研的,反而是不称职。第二次会议,王总为公司找到好的资源,希望加强沟通,安排开会促进交流,这也是值得肯定的。


但是,对于每一个中层管理者,却不能让高层和基层进行100%的信息交换。尤其是向领导转发员工的截图,或者向员工转发领导的截图。


我经常看很多中层做类似的转发:给老板发某某员工抱怨公司的话,给员工发老板嫌弃员工不加班的截屏。这种行为很像是友商派来的内鬼。


大多数情况下,一个职场人了解自己的直接领导需要什么,但是不会很了解领导的领导的领导需要什么。一个领导多数理解直接下属怎么想,但是无法理解下属的下属怎么想。


每一位中层管理者,不管是上传和下达,都要做一次信息的过滤和加工。比如领导抱怨员工不加班,中层需要做的不是转发,而是加紧工作计划,说要提前上线,让员工忙起来。你要一说就是老板要看加班,还排好张三加二四六,李四加一三五,那两头都得气疯了。


我的两个例子就是个反面教材。


其实,我不需要直接给王总汇报。我至多向上汇报两级(如果他们真的非要分那么多级)。某级领导结合王总的近期规划,甚至最近的心情,去做一次简要汇报。而对于同一件事情来说,如果一个基层员工参会的次数,远超过领导参会的次数,这可能是一个预警。它表示,中层管理者根本没有加工信息,完全走转发路线。


王总安排给产品总监的会议,后来我也发现其实是高层之间的会议。安排我去参加确实意义不大。让我主讲更不可取。因为我了解的信息太少了,哪个叫张总,哪个是孙总,他们之间是什么样的商业关系,他们相互间的地位如何。如果非让我参加,应当是提前打好招呼,并且把我安排到殿外侯旨,问我时我再回答。


这些,基本上都得需要中层管理者来考虑。


对于上传下达,能做好过滤和加工,这样的中层是伟大的。啥也不做,这样的中层很难成长。添油加醋,煽风点火,这样的中层不予评价。


我碰到的产品总监是个聪明人,不在以上之列。他从一开始往外放对话截图,其实就表明了态度:其实我也不想开这会,但是领导非要开,还安排给我,大家配合一下,就混过去了。


但是,走到我这儿,我却发了一个牢骚。我感觉,第一,你不愿开你就跟领导直说,愿意开就用心安排,那是你的直接领导。第二,你给我一个会议号就完了,你这不是让我配合,是完全转交给我了呀。


再反过来讲,这会议真的没有意义吗?来了好的业务资源,我们不该去把握住吗?怎么一件好事,最后落得人人都不爽的地步。我抢了你的钱,局面是我赢你输。但是,一个事情搞得大家都输的情况,也是很难的。


活,还是我干了,事儿我也惹了。我始终还是没能当成一个,传统意义上,让你开会你就开会,哪儿那么多废话的俗人。可能这世界很需要俗人。


最后,奉劝大家在公司少发表意见。尤其和领导沾边的言论。


作者:TF男孩
链接:https://juejin.cn/post/7180517435307458617
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

从《孤注一掷》出发,聊聊 SSL 证书的重要性

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。  图片来源于电影《孤注一掷》 这部电影除...
继续阅读 »

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。 


 图片来源于电影《孤注一掷》


这部电影除了让人后背发凉外,也不禁让人回忆起了曾经上网冲浪遇到的种种现象:看小说时性感荷官总在网页右下角在线发牌;看电影时网页左下角常常蹦出“在线老虎机”……这些让人烦不胜烦的广告弹窗之所以出现,要么是建站人员利欲熏心投放了非法广告,要么就是因为网站使用了不安全的 HTTP 协议而遭到了攻击,正常的网页内容被恶意篡改。


网站是电信诈骗、网络赌博等非法内容出现的重灾区,建站者和使用者都应该提高安全意识,特别是对建站者来说,保护通信安全才能更好的承担起建站责任。本文将从 HTTP 讲起,介绍 HTTPS 保护通信安全的原理,以及作为网络通信安全基石的 SSL 证书的重要性。


HTTP 协议


HTTP(Hyper Text Transfer Protocol)协议是超文本传输协议。它是从 WEB 服务器传输超文本标记语言(HTML)到本地浏览器的传送协议。HTTP 基于 TCP/IP 通信协议来传递数据,通信双方在 TCP 握手后即可开始互相传输 HTTP 数据包。具体过程如下图所示: 


 HTTP 建立流程


HTTP 协议中,请求和响应均以明文传输。如下图所示,在访问一个使用 HTTP 协议的网站时,通过抓包软件可以看到网站 HTTP 响应包中的完整 HTML 内容。




虽然 HTTP 明文传输的机制在性能上带来了优势,但同时也引入了安全问题:

  • 缺少数据机密性保护。HTTP 数据包内容以明文传输,攻击者可以轻松窃取会话内容。
  • 缺少数据完整性校验。通信内容以明文传输,数据内容可被攻击者轻易篡改,且双方缺少校验手段。
  • 缺少身份验证环节。攻击者可冒充通信对象,拦截真实的 HTTP 会话。

HTTP 劫持


作为划时代的互联网通信标准之一,HTTP 协议的出现为互联网的普及做出了不可磨灭的贡献。但正如上节谈到, HTTP 协议因为缺少加密、身份验证的过程导致很可能被恶意攻击,针对 HTTP 协议最常见的攻击就是 HTTP 劫持。


HTTP 劫持是一种典型的中间人攻击。HTTP 劫持是在使用者与其目的网络服务所建立的数据通道中,监视特定数据信息,当满足设定的条件时,就会在正常的数据流中插入精心设计的网络数据报文,目的是让用户端程序解析“错误”的数据,并以弹出新窗口的形式在使用者界面展示宣传性广告或直接显示某网站的内容。


下图是一种典型的 HTTP 劫持的流程。当客户端给服务端发送 HTTP 请求,图中发送请求为“梁安娜的电话号码是?”,恶意节点监听到该请求后将其放行给服务端,服务端返回正常 HTML 响应,关键返回内容本应该是“+86 130****1234”,恶意节点监听到该响应,并将关键返回内容篡改为泰国区电话“+66 6160 *88”,导致用户端程序展示出错误信息,这就是 HTTP 劫持的全流程。



 HTTP 劫持流程


例如,在某网站阅读某网络小说时,由于该网站使用了不安全的 HTTP 协议,攻击者可以篡改 HTTP 相应的内容,使网页上出现与原响应内容无关的广告,引导用户点击,可能将跳转进入网络诈骗或其他非法内容的页面。



 原网页



 HTTP 劫持后网页


HTTPS 工作原理


HTTPS 协议的提出正是为了解决 HTTP 带来的安全问题。HTTPS 协议(HyperText Transfer Protocol Secure,超文本传输安全协议),是一种通过计算机网络进行安全通信的传输协议。HTTPS 经由 HTTP 进行通信,但利用 SSL/TLS 来加密数据包。HTTPS 的开发主要是提供对网站服务器的身份认证,保护交换资料的隐私性与完整性。


TLS 握手是 HTTPS 工作原理的安全基础部分。TLS 传统的 RSA 握手流程如下所示:



 TLS 握手流程


TLS 握手流程主要可以分为以下四个部分:


第一次握手:客户端发送 Client Hello 消息。该消息包含:客户端支持的 SSL/TLS 协议版本(如 TLS v1.2 );用于后续生成会话密钥的客户端随机数 random_1;客户端支持的密码套件列表。


第二次握手:服务端收到 Client Hello 消息后,保存随机数 random_1,生成随机数 random_2,并发送以下消息。

  • 发送 Server Hello 消息。该消息包含:服务端确认的 SSL/TLS 协议版本(如果双方支持的版本不同,则关闭加密通信);用于后续生成会话密钥的服务端随机数 random_2;服务端确认使用的密码套件
  • 发送“Server Certificate”消息。该消息包含:服务端的 SSL 证书。SSL 证书又包含服务端的公钥、身份等信息。
  • 发送“Server Hello Done”消息。该消息表明 ServerHello 及其相关消息的结束。发送这个消息之后,服务端将会等待客户端发过来的响应。

第三次握手:客户端收到服务端证书后,首先验证服务端证书的正确性,校验服务端身份。若证书合法,客户端生成预主密钥,之后客户端根据(random_1, random_2, 预主密钥)生成会话密钥,并发送以下消息。

  • 发送“Client Key Exchange”消息,该消息为客户端生成的预主密钥,预主密钥会被服务端证书中的公钥加密后发送。
  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。
  • 发送“Encrypted Handshake Message”消息,表示客户端的握手阶段已经结束。客户端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给服务端,供服务端校验。

第四次握手:服务端收到客户端的消息后,利用自己的服务端证书私钥解密出预主密钥,并根据(random_1, random_2, 预主密钥)计算出会话密钥,之后发送以下消息。

  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。
  • 发送“Encrypted Handshake Message”,表示服务端的握手阶段已经结束,同时服务端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给客户端,供客户端校验。

根据 TLS 握手流程,可以看出它是如何解决 HTTP 协议缺陷,以及避免中间人攻击的:


1.规避窃听风险,攻击者无法获知通信内容


在客户端进行真正的 HTTPS 请求前,客户端与服务端都已经拥有了本次会话中用于加密的对称密钥,后续双方 HTTPS 会话中的内容均用该对称密钥加密,攻击者在无法获得该对称密钥的情况下,无法解密获得会话中内容的明文。即使攻击者获得了 TLS 握手中双方发送的所有明文信息,也无法从这些信息中恢复对称密钥,这是由大数质因子分解难题和有限域上的离散对数难题保证的。


2.规避篡改风险,攻击者无法篡改通信内容


在数据通信阶段,双端消息发送时会对原始消息做一次哈希,得到该消息的摘要后,与加密内容一起发送。对端接受到消息后,使用协商出来的对称加密密钥解密数据包,得到原始消息;接着也做一次相同的哈希算法得到摘要,对比发送过来的消息摘要和计算出的消息摘要是否一致,可以判断通信数据是否被篡改。


3.规避冒充风险,攻击者无法冒充身份参与通信


在 TLS 握手流程中的第二步“Server Hello”中,服务端将自己的服务端证书交付给客户端。客户端拿到 SSL 证书后,会对服务端证书进行一系列校验。以浏览器为例,校验服务端证书的过程为:

  • 验证证书绑定域名与当前域名是否匹配。
  • 验证证书是否过期,是否被吊销。
  • 查找操作系统中已内置的受信任的证书发布机构 CA(操作系统会内置有限数量的可信 CA),与服务端证书中的颁发者 CA 比对,验证证书是否为合法机构颁发。如果服务端证书不是授信 CA 颁发的证书,则浏览器会提示服务端证书不可信。
  • 验证服务端证书的完整性,客户端在授信 CA 列表中找到服务端证书的上级证书,后使用授信上级证书的公钥验证服务端证书中的签名哈希值。
  • 在确认服务端证书是由国际授信 CA 签发,且完整性未被破坏后,客户端信任服务端证书,也就确认了服务端的正确身份。

SSL 证书


正如上一节介绍,SSL 证书在 HTTPS 协议中扮演着至关重要的作用,即验证服务端身份,协助对称密钥协商。只有配置了 SSL 证书的网站才可以开启 HTTPS 协议。在浏览器中,使用 HTTP 的网站会被默认标记为“不安全”,而开启 HTTPS 的网站会显示表示安全的锁图标。



 使用 HTTP 协议的网站



 使用 HTTPS 协议的网站


从保护范围、验证强度和适用类型出发, SSL 证书会被分成不同的类型。只有了解类型之间的区别,才能根据实际情况选择更适合的证书类型,保障通信传输安全。


从保护范围分,SSL 证书可以分为单域名证书、通配符证书、多域名证书。

  • 单域名证书:单域名证书只保护一个域名,这些域名形如 http://www.test.com 等。
  • 通配符证书:通配符证书可以保护基本域和无限的子域。通配符 SSL 证书的公用名中带有星号 ,其中,星号表示具有相同基本域的任何有效子域。例如,。test.com 的通配符证书可用于保护 a.test.com、 b.test.com……
  • 多域名证书:多域证书可用于保护多个域或子域。包括完全唯一的域和具有不同顶级域的子域(本地/内部域除外)的组合。

从验证强度和适用类型进一步区分,SSL 证书可以分为 DV、OV、EV 证书。

  • DV(Domain Validated):域名验证型。在颁发该类型证书时,CA 机构仅验证申请者对域名的所有权。CA 机构会通过检查 WHOIS、DNS 的特定记录来确认资格。一般来说,DV 证书适用于博客、个人网站等不需要任何私密信息的网站。
  • OV(Organization Validated):组织验证型。OV 证书的颁发除了要验证域名所有权外,CA 还会额外验证申请企业的详细信息(名称、类型、地址)等。一般来说,OV 证书适用于中级商业组织。
  • EV(Extended Validation):扩展验证型。EV 证书的颁发除了 CA 对 DV 和 OV 证书所采取的所有身份验证步骤之外,还需要审查商业组织是否在真实运营、其实际地址,并致电以验证申请者的就业情况。一般来说,EV 证书适用于顶级商业组织。

结尾


随着互联网应用的普及,网络诈骗的方式也越发花样百出,让人防不胜防。


除了文内提到的网页环境,在软件应用、邮件、文档、物联网等领域同样存在恶意软件、钓鱼邮件、文档篡改、身份认证的问题。幸运的是,作为 PKI 体系下的优秀产品,证书体系同样在这些领域发挥着重要作用,软件签名证书、邮件签名证书、文档签名证书、私有证书等保护着各自领域的信息安全。


总有不法分子企图通过漏洞牟利,而证书体系在保护数据机密性、完整性、可用性以及身份验证场景上有着无可取代的地位,牢牢守护着用户信息,保障通信安全。


推荐活动


火山引擎域名与网站特惠活动来啦,欢迎访问火山引擎官网抢购!


5 折抢购 SSL 证书、1 元注册/转入域名、1 元升级 DNS 专业版、HTTPDNS 资源包 1 折起火热进行中……


此外,火山引擎已新推出:

  • 私有 CA(Private CA/PCA),通过私有证书灵活标识和保护企业内部资源和数据
  • 商标服务,专业、高效的商标注册管理服务平台
  • 私网解析 PrivateZone,灵活构建 VPC 内的私网域名系统
  • 公共解析PublicDNS,快速安全的递归DNS,永久免费
  • 域名委托购买服务,0元下单即可尝试获取心仪域名

关于火山引擎边缘云:
火山引擎边缘云,以云原生技术为基础底座,融合异构算力和边缘网络,构建在大规模边缘基础设施之上的云计算服务,形成以边缘位置的计算、网络、存储、安全、智能为核心能力的新一代分布式云计算解决方案。


作者:火山引擎边缘云
链接:https://juejin.cn/post/7273685263841263672
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

ChatGPT明知自己写代码有漏洞,但你不问它就不说

萧箫 发自 凹非寺 量子位 | 公众号 QbitAI ChatGPT知道自己写的代码有 漏洞,但它不说! 来自加拿大的一项最新研究发现,ChatGPT生成的代码中,有不少都存在安全漏洞。 然而在被要求评估自己代码的安全性时,ChatGPT却很快发现了这些代码中...
继续阅读 »

萧箫 发自 凹非寺


量子位 | 公众号 QbitAI


ChatGPT知道自己写的代码 漏洞,但它不说!


来自加拿大的一项最新研究发现,ChatGPT生成的代码中,有不少都存在安全漏洞。


然而在被要求评估自己代码的安全性时,ChatGPT却很快发现了这些代码中的漏洞,并给出了一些解决方案和建议。




这意味着它并不知道自己生成了糟糕的代码,但却查得出它写的代码有漏洞,也有能力修复这些漏洞


而在另一篇来自斯坦福的论文中,研究人员测试了另一位著名AI程序员Copilot,也发现了类似的问题。


所以,用AI生成代码为啥会出现这种状况?


写的程序中76%有安全漏洞


研究人员试着让ChatGPT生成了21个程序。


整个测试过程如下,先提交需求给ChatGPT,生成相关代码,再对这些代码进行测试,并检查问题、潜在的缺陷和漏洞等。




研究人员给ChatGPT提了包括C++、C、Java和Python在内的21个写代码需求,这是评估的结果:




统计表明,ChatGPT生成的21个程序中,有17个能直接运行,但其中只有5个程序能勉强通过程序安全评估,不安全代码率达到76%以上。


于是,研究人员先试着让ChatGPT“想想自己生成的代码有啥问题”。


ChatGPT的回应是“没啥问题”:只要用户每次的输入都是有效的,那么程序一定能运行!


显然ChatGPT并没有意识到,用户并不都是行业专家,很可能只需要一个无效输入,就能“引炸”它写的程序:




发现ChatGPT不知道自己写的程序不安全后,研究人员尝试换了种思路——用更专业的语言提示ChatGPT,如告诉它这些程序具体存在什么漏洞。


神奇的是,在听到这些针对安全漏洞的专业建议后,ChatGPT立刻知道自己的代码存在什么问题,并快速纠正了不少漏洞。


经过一番改进后,ChatGPT终于将剩余的16个漏洞程序中的7个改得更安全了。


研究人员得出结论认为,ChatGPT并不知道自己的代码中存在安全漏洞,但它却能在生成程序后识别其中的漏洞,并尝试提供解决方案。


论文还指出,ChatGPT虽然能准确识别并拒绝“写个攻击代码”这种不道德的需求,然而它自己写的代码却有安全漏洞,这其实有着设计上的不合理之处。


我们试了试发现,ChatGPT确实会主动拒绝写攻击性代码的要求:




大有一种“我不攻击别人,别人也不会攻击我写的代码”自信感。


程序员们在用它辅助写代码的时候,也需要考虑这些问题。


Copilot也存在类似问题


事实上,不止ChatGPT写的代码存在安全问题。


此前,斯坦福大学的研究人员对Copilot也进行过类似调查,只不过他们探查的是用Copilot辅助生成的程序,而并非完全是Copilot自己写的代码。


研究发现,即便Copilot只是个“打辅助”的角色,经过它改写的代码中,仍然有40% 出现了安全漏洞。




而且研究只调查了Copilot生成代码中的一部分,包括C、Python和Verilog三种编程语言写的程序,尚不知道用其他语言编写的程序中,是否还存在更多或更少的安全漏洞。


基于此,研究人员得出了如下结论:



ChatGPT等AI生成的代码安全性并不稳定,用某些语言写的代码比较安全,而用其他语言写的代码却很容易遭受攻击。整体来看,它们就是一个黑盒子,生成的代码是有风险的。


这并不意味着AI代码工具不能用,只是我们在使用时,必须考虑这些代码的安全性。



作者介绍


四位作者均来自加拿大魁北克大学(Universite du Quebec en Outaouais)。




Raphaël Khoury,加拿大魁北克大学教授,曾经在拉瓦尔大学获得计算机学士、硕士和博士学位,研究兴趣集中在计算机安全方面。




Anderson R. Avila,魁北克大学国立科学研究院的助理教授,主要研究方向是数据隐私相关的联邦学习、网络安全和生物特征识别技术等,曾经在华为的加拿大研究院做过机器学习研究员。


作者Jacob Brunelle和Baba Mamadou Camara也都来自加拿大魁北克大学。


你用ChatGPT写过代码吗?感觉它的“安全意识”如何?


论文地址:

arxiv.org/abs/2304.09…


生成代码数据集:

github.com/RaphaelKhou…


—  —


作者:量子位
链接:https://juejin.cn/post/7225565801792913466
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

每个人都深陷自己的信息茧房当中

平时没事,喜欢观察观察路人,猜测一下他(她)是干什么的,是什么样的生活状态。同时,也会作为旁观着看看别人的为人处世、观点、思想等,用于自我学习。 但这些也仅仅是作为上帝视角,看一看,听一听,想一想。无论对错,都不会干预,那毕竟是别人的事。但从这些观察之中,还是...
继续阅读 »

平时没事,喜欢观察观察路人,猜测一下他(她)是干什么的,是什么样的生活状态。同时,也会作为旁观着看看别人的为人处世、观点、思想等,用于自我学习。


但这些也仅仅是作为上帝视角,看一看,听一听,想一想。无论对错,都不会干预,那毕竟是别人的事。但从这些观察之中,还是能够学到很多。


时间久了,你会发现,几乎所有的人都陷入在自己构建的信息茧房当中。几乎所有的行为和决策都是过往经历的延续和习惯依赖。


聊聊生活中的几件事:


第一,何不食肉糜


在《晋书·惠帝纪》中记录着这样一个故事:


晋代有一年发生饥荒,百姓没有粮食吃,只能吃草根和树皮,甚至活活饿死。晋惠帝听了奏报,大为不解,问:他们为什么不吃肉粥呢?


晋惠帝也正因为这一句话,被后人所记住。


他的回答看似非常荒诞,但其实我们在日常生活中,大多数时候都在做着类似的事情,特别是给别人提建议的时候。


第二,手中的锤子


曾经有一段项目经历,由于是创新项目,需要大家集思广益。团队成员也来自各个行业,比如做游戏的、做二次元的、做漫画的等。


在最终碰创意时,你会发现,每个人的创意都深深刻着他过往的经历和行业经验,每个人都觉得自己的观点和想法是正确的,是无懈可击的。而在旁观者或者没有相关经验的人看来,多少有些不可思议。


有时候,你会发现,自己认为很简单的事情,给别人解释半天,别人依旧没有懂。这可能是你讲解的不好,也可能是对方没有大量相关知识的铺垫,导致无法理解那个“简单的结论”。


这里有知识的诅咒,也有信息的茧房。知识的诅咒让你想象不到不知道这一知识的人会怎么想,而信息的茧房导致你只认可你自己所“筛选”下来的信息。


第三,不要让别人推荐书


一直有很多朋友想读书,让推荐书籍。其实这个事非常难做,因为不知道你喜欢什么,也不知道是否适合你。


每个人的知识背景不同,同样一本书,有的人读起来可能刚刚好,但有的人读起来则十分苦难,味同嚼蜡。


其实,刚开始读书,去看那些自己感兴趣、又刚好能读懂的书,让兴趣、难度、需求同时匹配到舒适区边缘,这样的书肯定会让你读得津津有味。


第四,关于恶评


凡是在网络上发表过文章的人几乎都会遇到一件事:喷子+恶评。


这里分两类,一类单纯是为了发泄情绪的攻击,无其他内容。这一类,要么无视,要么哈哈大笑即可。这说明对方不但嫉妒你,还不如你,因为他没法拿出更好的作品或观点来回应,只会发泄情绪、肆意谩骂。


另外一类看似言之凿凿,但你会发现,他陷入了深深的信息茧房之中。比如他心中所想,生活所遇皆是消极、邪恶之念,那么你的想法、说辞、行为再正面,他也能用他邪恶负面的想法包装一番,形成逻辑自洽。然后用这些观点来抨击、谩骂和恶评。


针对这些人,他的每一个字都透露着他思想的狭隘,何必与他计较呢。如果一个人说三七得四十八,你还去跟他争辩,那就是你的不对了。


每个人所说的话,所看到的世界,都不是这个世界的客观存在,而只是内心对现实世界的反映,对这个世界认知的呈现而已。所以那些恶评,除了彰显、铭记他的无知之外,别无他用,何必计较。


最后的小结


不可否认,我们每个人对这个世界都有着不同的认知,而这些认知往往都来源于过往的经历。这些经历塑造了现在的我们,塑造了我们对世界的看法,影响着我们的决策。同时,它们也将会被我们的决策和行动进一步强化。


而要打破这些认知的茧房,更多的是需要多看看不同行业,不同领域,不同视角,不阶层,不同角色的看法。拥有了更多的视角,甚至相互不兼容的多个视角,才能更好的跳出茧房,更清晰的看到这个世界的客观存在。


作者:程序新视界
来源:juejin.cn/post/7271896547595403324
收起阅读 »

电视剧里的代码真能运行吗?

大家好,欢迎来到 Crossin的编程教室 ! 前几天,后台老有小伙伴留言“爱心代码”。这不是Crossin很早之前发过的内容嘛,怎么最近突然又被人翻出来了?后来才知道 ,原来是一部有关程序员的青春偶像剧《点燃我,温暖你》在热播,而剧中有一段关于期中考试要用程...
继续阅读 »

大家好,欢迎来到 Crossin的编程教室 !


前几天,后台老有小伙伴留言“爱心代码”。这不是Crossin很早之前发过的内容嘛,怎么最近突然又被人翻出来了?后来才知道


,原来是一部有关程序员的青春偶像剧《点燃我,温暖你》在热播,而剧中有一段关于期中考试要用程序画一个爱心的桥段。


于是出于好奇,Crossin就去看了这一集(第5集,不用谢)。这一看不要紧,差点把刚吃的鸡腿给喷出来--槽点实在太多了!


忍不住做了个欢乐吐槽向的代码解读视频,在某平台上被顶到了20个w的浏览,也算蹭了一波人家电视剧的热度吧……


下面是图文版,给大家分析下剧中出现的“爱心”代码,并且来复刻一下最后男主完成的酷炫跳动爱心。


剧中代码赏析


1. 首先是路人同学的代码:



虽然剧中说是“C语言期中考试”,但这位同学的代码名叫 draw2.py,一个典型的 Python 文件,再结合截图中的 pen.forward、pen.setpos 等方法来看,应该是用 turtle 海龟作图库来画爱心。那效果通常是这样的:


import turtle as t
t.color('red')
t.setheading(50)
t.begin_fill()
t.circle(-100, 170)
t.circle(-300, 40)
t.right(38)
t.circle(-300, 40)
t.circle(-100, 170)
t.end_fill()
t.done()



而不是剧中那个命令行下用1组成的不规则的图形。


2. 然后是课代表向路人同学展示的优秀代码:



及所谓的效果:



这确实是C语言代码了,但文件依然是以 .py 为后缀,并且 include 前面没有加上 #,这显然是没法运行的。


里面的内容是可以画出爱心的,用是这个爱心曲线公式:



然后遍历一个15*17的方阵,计算每个坐标是在曲线内还是曲线外,在内部就输出#或*,外部就是-


用python改写一下是这样的:


for y in range(9, -6, -1):
for x in range(-8, 9):
print('*##*'[(x+10)%4] if (x*x+y*y-25)**3 < 25*x*x*y*y*y else '-', end=' ')
print()

效果:



稍微改一下输出,还能做出前面那个全是1的效果:


for y in range(9, -6, -1):
for x in range(-8, 9):
print('1' if (x*x+y*y-25)**3 < 25*x*x*y*y*y else ' ', end=' ')
print()


但跟剧中所谓的效果相去甚远。


3. 最后是主角狂拽酷炫D炸天的跳动爱心:



代码有两个片段:




但这两个片段也不C语言,而是C++,且两段并不是同一个程序,用的方法也完全不一样。


第一段代码跟前面一种思路差不多,只不过没有直接用一条曲线,而是上半部用两个圆形,下半部用两条直线,围出一个爱心。



改写成 Python 代码:


size = 10
for x in range(size):
for y in range(4*size+1):
dist1 = ((x-size)**2 + (y-size)**2) ** 0.5
dist2 = ((x-size)**2 + (y-3*size)**2) ** 0.5
if dist1 < size + 0.5 or dist2 < size + 0.5:
print('V', end=' ')
else:
print(' ', end=' ')
print()

for x in range(1, 2*size):
for y in range(x):
print(' ', end=' ')
for y in range(4*size+1-2*x):
print('V', end=' ')
print()

运行效果:



第二段代码用的是基于极坐标的爱心曲线,是遍历角度来计算点的位置。公式是:



计算出不同角度对应的点坐标,然后把它们连起来,就是一个爱心。


from math import pi, sin, cos
import matplotlib.pyplot as plt
no_pieces = 100
dt = 2*pi/no_pieces
t = 0
vx = []
vy = []
while t <= 2*pi:
vx.append(16*sin(t)**3)
vy.append(13*cos(t)-5*cos(2*t)-2*cos(3*t)-cos(4*t))
t += dt
plt.plot(vx, vy)
plt.show()

效果:



代码中循环时用到的2π是为了保证曲线长度足够绕一个圈,但其实长一点也无所谓,即使 π=100 也不影响显示效果,只是相当于同一条曲线画了很多遍。所以剧中代码里写下35位小数的π,还被女主用纸笔一字不落地抄写下来,实在是让程序员无法理解的迷惑行为。



但不管写再多位的π,上述两段代码都和最终那个跳动的效果差了五百只羊了个羊。


跳动爱心实现


作为一个总是在写一些没什么乱用的代码的编程博主,Crossin当然也不会放过这个机会,下面就来挑战一下用 Python 实现最终的那个效果。


1. 想要绘制动态的效果,必定要借助一些库的帮助,不然代码量肯定会让你感动得想哭。这里我们将使用之前 羊了个羊游戏 里用过的 pgzero 库。然后结合最后那个极坐标爱心曲线代码,先绘制出曲线上离散的点。


import pgzrun
from math import pi, sin, cos

no_p = 100
dt = 2*3/no_p
t = 0
x = []
y = []
while t <= 2*3:
x.append(16*sin(t)**3)
y.append(13*cos(t)-5*cos(2*t)-2*cos(3*t)-cos(4*t))
t += dt

def draw():
screen.clear()
for i in range(len(x)):
screen.draw.filled_rect(Rect((x[i]*10+400, -y[i]*10+300), (4, 4)), 'pink')

pgzrun.go()


2. 把点的数量增加,同时沿着原点到每个点的径向加一个随机数,并且这个随机数是按照正态分布来的(半个正态分布),大概率分布在曲线上,向曲线内部递减。这样,就得到这样一个随机分布的爱心效果。


...
no_p = 20000
...
while t <= 2*pi:
l = 10 - abs(random.gauss(10, 2) - 10)
x.append(l*16*sin(t)**3)
y.append(l*(13*cos(t)-5*cos(2*t)-2*cos(3*t)-cos(4*t)))
t += dt
...


3. 下面就是让点动起来,这步是关键,也有一点点复杂。为了方便对于每个点进行控制,这里将每个点自定义成了一个Particle类的实例。


从原理上来说,就是给每个点加一个缩放系数,这个系数是根据时间变化的正弦函数,看起来就会像呼吸的节律一样。


class Particle():
def __init__(self, pos, size, f):
self.pos = pos
self.pos0 = pos
self.size = size
self.f = f

def draw(self):
screen.draw.filled_rect(Rect((10*self.f*self.pos[0] + 400, -10*self.f*self.pos[1] + 300), self.size), 'hot pink')

def update(self, t):
df = 1 + (2 - 1.5) * sin(t * 3) / 8
self.pos = self.pos0[0] * df, self.pos0[1] * df

...

t = 0
def draw():
screen.clear()
for p in particles:
p.draw()

def update(dt):
global t
t += dt
for p in particles:
p.update(t)


4. 剧中爱心跳动时,靠中间的点波动的幅度更大,有一种扩张的效果。所以再根据每个点距离原点的远近,再加上一个系数,离得越近,系数越大。


class Particle():
...
def update(self, t):
df = 1 + (2 - 1.5 * self.f) * sin(t * 3) / 8
self.pos = self.pos0[0] * df, self.pos0[1] * df


5. 最后再用同样的方法画一个更大一点的爱心,这个爱心不需要跳动,只要每一帧随机绘制就可以了。


def draw():
...
t =
0
while t < 2*pi:
f = random.gauss(1.1, 0.1)
x = 16*sin(t)**3
y = 13*cos(t)-5*cos(2*t)-2*cos(3*t)-cos(4*t)
size = (random.uniform(0.5,2.5), random.uniform(0.5,2.5))
screen.draw.filled_rect(Rect((10*f*x + 400, -10*f*y + 300), size), 'hot pink')
t += dt * 3


合在一起,搞定!



总结一下,就是在原本的基础爱心曲线上加上一个正态分布的随机量、一个随时间变化的正弦函数和一个跟距离成反比的系数,外面再套一层更大的随机爱心,就得到类似剧中的跳动爱心效果。


但话说回来,真有人会在考场上这么干吗?


除非真的是超级大学霸,不然就是食堂伙食太好--


吃太饱撑的……



代码已开源:https://gitee.com/crossin/easy-py/tree/master/221114%20%E7%88%B1%E5%BF%83%E4%BB%A3%E7%A0%81



作者:Crossin先生
来源:juejin.cn/post/7168388057631031332
收起阅读 »

一条SQL差点引发离职

排除一切不可能的,剩下的即使再不可能,那也是真相” 背景        最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。 本来是一个很简单的逻辑,就...
继续阅读 »

排除一切不可能的,剩下的即使再不可能,那也是真相”



背景


       最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。

本来是一个很简单的逻辑,就是根据唯一的id去更新对应的MySQL数据,代码简化后如下:


var updates []*model.Goods
for id, newGoods := range update {
 if err := model.GetDB().Model(&model.Goods{}).Where("id = ?", id).Updates(map[string]interface{}{
  "selling_price":  newGoods.SellingPrice,
  "sell_type":      newGoods.SellType,
  "status":         newGoods.Status,
  "category_id":    newGoods.CategoryID,
 }).Error; err != nil {
  return nil, err
 }
}

很明显,updates[]model.Goods\color{red}{updates []*model.Goods}本来应该是想声明为 map[string]model.Goods\color{red}{map[string]*model.Goods}类型的,然后key是唯一id。这样下面的更新逻辑才是对的,否则拿到的id其实是数组的下标。

但是code review由于跟着一堆代码一起评审了,并且这段更新很简单,同时测试的时候也测试过了(能测试通过也是“机缘巧合”),所以没有发现这段异常。

发到线上后,进行了灰度集群的测试,这个时候发现只要调用了这个接口,灰度集群的数据全部都变成了一样,回滚后正常。


分析


       回滚后在本地进行复现,由于本地环境是开启了SQL打印的,于是看到了这么一条SQL:很明显是拿数组的下标去比较了


update db_name set selling_price = xx,sell_type = xx where id = 0;

       由于我们的id是全部是通过uuid生成的,所以下意识的认为这条sql应该啥也不会更新才对,但是本地的确只执行了这条sql,没有别的sql,并且db中的数据全部都被修改了。

这个时候想起福尔摩斯的名言“排除一切不可能的,剩下的即使再不可能,那也是真相”\color{blue}{“排除一切不可能的,剩下的即使再不可能,那也是真相”} ,于是抱着试一试的心态直接拿这条sql去db控制台执行了一遍,发现果然所有的数据又都被修改了。

也就是 whereid=0\color{red}{where id = 0}  这个条件对于所有的记录都是恒为true,就会导致所有记录都被更新。在这个时候,想起曾经看到过MySQL对于不同类型的比较会有 【隐式转换】\color{red}{【隐式转换】},难道是这个原因导致的?


隐式转换规则


在MySQL官网找到了不同类型比较的规则:



最后一段的意思是:对于其他情况,将按照浮点(双精度)数进行比较。例如,字符串和数字的比较就按照浮点数规则进行比较。

也就是id会首先被转换成浮点数,然后再跟0进行比较。


MySQL字符转为浮点数时会按照如下规则进行:


1.如果字符串的第一个字符就是非数字的字符,那么转换结果就是0;

2.如果字符串以数字开头:

(1)如果字符串都是数字,转换结果就是整个字符串对应的数字;

(2)如果字符串中存在非数字,转换结果就是开头的那些数字对应的值;

举例说明:

"test" -> 0

"1test" -> 1

"12test12" -> 12

由于我们生成的uuid没有数字开头的字符串,于是都会转变成0。那么这条SQL就变成了:


update db_name set selling_price = xx,sell_type = xx where 0 = 0;

就恒为true了。

修复就很简单了,把取id的逻辑改成正确的就行。


为什么测试环境没有发现


       前面有提到这段代码在测试环境是测试通过了的,这是因为开发和测试同学的环境里都只有一条记录,每次更新他发现都能正常更新就认为是正常的了。同时由于逻辑太简单了,所以都没有重视这块的回归测试。

幸好在灰度集群就发现了这个问题,及时进行了回滚,如果发到了线上影响了用户数据,可能就一年白干了。


最后


代码无小事,事事需谨慎啊。一般致命问题往往是一行小小的修改导致的。


作者:云舒编程
来源:juejin.cn/post/7275550679790960640
收起阅读 »

25k大专前端外包从深圳回武汉能拿多少?

2023 年 08 月我正式从深圳公司离职,从 7 月初开始投武汉的公司,截止 8 月底,2 个月时间有 5 个面试 3 个 offer:一个自研的 22k、两个外包一个 17k,一个 18k。最终选了一家离我比较近的 18k offer,但遗憾的是刚去第一天...
继续阅读 »

2023 年 08 月我正式从深圳公司离职,从 7 月初开始投武汉的公司,截止 8 月底,2 个月时间有 5 个面试 3 个 offer:一个自研的 22k、两个外包一个 17k,一个 18k。最终选了一家离我比较近的 18k offer,但遗憾的是刚去第一天就发现坑太深,还是决定放弃这家公司,目前失业在家,这里和大家聊一聊最近从深圳回武汉找工作的经历。


基本情况


先大致介绍下我的基本情况:大专学历,30+,从 17 年开始做全职前端开发,到现在有 6-7 年了,属于一年工作经验用 5 年的那种,Vue 一把梭,技术一般。进不了中、大厂,只能在外包混混日子。


时间回到两年前,2021 年 8 月前端行情好的时候,我凭运气找到了一家深圳二线互联网公司的前端外包岗位,offer 是 25k * 12,到今年 8 月正好呆了两年左右,为了说明真实性,下面附上我 2022 年度个税 App 收入纳税截图。


2-2022.png


声明


本人在网上冲浪时从未公开过这家我呆了两年的公司,出于薪资保密原则,如果有人认出我,知道我所在的这家公司,还请不要透露公司名字,万分感谢!


另外,为了避免纠纷,后面面试的公司,我都会进行匿名处理,如果有人猜到公司名称,评论还请使用化名,希望大家能理解。


深圳回武汉


从 21 年入职这家公司开始,这两年前端行情越来越差,目前我这个学历、技术水平比较难找到 25k 以上的工作。我有考虑过要不要先苟在这家公司,毕竟这个工作工资还可以,leader、同事、工作氛围都不错。但由于各方面原因,最终还是决定回武汉。



  1. 工作方面:我做的大多是技术需求,做的比较无聊,成就感较低;另外,新需求越来越少,蛋糕就这么大,僧多粥少,发挥空间较小。

  2. 生活方面:我时常在反思,我是不是一个精致的利己主义者?这些年基本就过年回家,回家也呆不了几天,在照顾父母、关心家人这方面我是做的比较差的,如果我只想着自己能不能拿高工资,自己过的是否惬意,我觉得这是很自私的,回武汉离家近可以很好的解决这个问题。


2-work.png


在业务需求少后,部门也有了裁员的消息,我正好在这边快两年了,也想回武汉换个环境。


为了变被动为主动,就在 7 月初开始投武汉的公司了,计划拿到 offer 就离职回去。因为在行业下行周期,越想往上挣扎越累,还不一定有好的结果,不如顺势躺平,好好享受生活。


简历投递面试数据


23 年 6 月 20 号左右,将简历开放,状态修改为在职-看机会。过了一段时间,发现没 hr 联系我,行情确实差了很多,之前简历一开放,一堆 hr 主动找你,这个时候还没主动投。


一直到 23 年 7 月 3 号,我终于修改好了简历,开始投简历。但如下图,简历比较难投出去,需要双方回复才能投。


3-boss-huifu.png


于是我又下载了拉钩、猎聘。拉钩猎聘大部分都可以直接投,但拉钩 20k+ 武汉的岗位很少,猎聘投了很多也没回复,整体还是 BOSS 上面试机会最多,下面是具体数据


App 类型沟通投递面试机会面试通过/Offer
BOSS13186142
猎聘-29411
拉钩-62(投递反馈)00

卡学历问题


我基本把武汉的 20k+ 前端岗位都投了一遍,但基本没有中、大厂都能通过简历筛选。分三种情况



  1. 没有任何回复(最多)

  2. 回复看了简历不合适(个别)

  3. 直接指出学历不符合(个别)


4-xueli.png


虽然我有自考本科+学士学位也没啥用,一般还是至少要统招本科及以上。当然也有可能会是年龄、技术菜、要的工资高等其他因素。


4-2-xueli.png


面试记录


某电商小公司 - 自研 22k(过)



来源:猎聘 App,岗位:中高级前端开发工程师(自研)(14-22k)



2023 年 7 月 10 号,在投了一个星期后,终于有了第一个面试,晚上 19:00 腾讯会议远程面,大概面了一个小时,问的问题不难,比如



  • 先自我介绍

  • 垂直居中有几种方式?

  • flex: 2 有用过吗?多列布局怎么实现?

  • 怎么判断对象为空?

  • 寻找字符串中出现最多的字符怎么实现?

  • 知不知道最新的 url 参数获取的 API?

  • 实现深拷贝

  • 实现 Promise

  • 新版本发布后,怎么用技术手段通知用户刷新页面?

  • 性能优化数据怎么上报、分析?

  • Vue 组件通信方式有哪些,各有什么特点?

  • Vue 项目怎么提高项目性能?举一些例子

  • element ui table 吸顶怎么做,滚动怎么处理等

  • 你有什么想问我的?


然后还问了一些项目问题,能不能加班,因为虽然双休,但周一到周五会有 3 天加班等。基本没有问啥原理性的问题,就是看基础怎么样,能不能干活。


面试第二天,没有消息,我以为挂了,但隔了一天,7 月 12 号,HR 电话二面,我问了我的一些基本情况后,表示可以直接发 offer,确定薪资为 22k,但其中 2.2k 要当做季度绩效发放,说的是一般不犯啥错误都可以拿到。下面是 offer 截图


5-offer-1.png


沟通入职时间定的 8 月 1 号,比较坑的是甲方都同意 7 月底可以走,外包公司这边不同意,要到 8 月中才放我走,合同确实是这样写的,我也不好说啥。


这家公司比较着急,觉得等的时间有点长了,1个月+,风险有点高。我也不能说让别人一直等,只能说,让他们可以先考虑其他候选人,这家公司过了段时间招到人了,这个 offer 就黄了。


(后面回想起来,我可能有点傻,规定是死的,人是活的,应该直接按甲方允许的 7 月底时间来,这样 offer 就没问题了。如果我们公司不让我走,我可以直接走人,就当旷工,直接被开除就行,只是没有离职证明,但工资流水是有的)


武汉某小公司 - 自研 (12-20k)x



来源:BOSS,岗位:前端开发工程师 - 自研(12-20k)14薪



在上面的 22k 这个 offer 时间有冲突的时候,我就意识到这个 offer 有风险,就开始继续投了。


到 23 年 8 月 2 号终于又有了面试机会,一面是笔试,如下图


6-hema.png


有 4 题,最后一题最简单,第 1、2 题忘记了,1、2、3 我都是用递归实现的,3、4 题如下



  1. _.flatten() 实现一个数组打平方法,支持第二个参数(可指定打平层级)


const array = [[0, 1], [2, [3, 4]], [5, 6]];
const result = _.flatten(array);


  1. 菜单数组转换为嵌套树形结构,但示例只有两级


[
{ id: 1, menu: '水果', level: 1 },
{ id: 2, menu: '橘子', level: 2, parentId: 1 }
// ...
]
// 转换为
[
{
id: 1, menu: '水果', level: 1, children: [{ id: 2, menu: '橘子', level: 2, parentId: 1 }]
},
// ...
]

笔试难度一般,主要靠思维,难度比 leetcode 算法题低,算是过了。


二面是 8 月 7 号电话面,19:00 - 20:00 一个小时左右,大部分问题都忘记了,模糊记得部分问题



  • 先自我介绍

  • 把之前的笔试题一题一题拿出来讲实现思路。

  • 对象的继承有哪几种?

  • TS 用的多吗?

  • 工作中解决的最有成就感的事?

  • vue3 在某些场景比 vue2 性能更低,为什么会这样?

  • 在团队协作时,有遇到过什么问题吗,如果有冲突你会怎么做

  • 你有什么想问我的?


另外面试小哥对我之前有两家半年左右的工作经历比较在意,问了很多之前公司的细节,因为他说之前有面试过的最后背调没通过,所以要问清楚。我的简历写的很真实,基本没有水分,是什么就是什么。


他最后透露,可能就算他可以过,但 HR 那边可能过不了,不知道是我跳槽太频繁还是啥,总之后面基本没消息了,这个算是挂了。


某上海武汉分公司 - 自研(18-23k)x



来源:BOSS,岗位:前端开发 自研(18-23k)



上次面试的挂了之后,继续投,但没面试机会,后面又忙搬家、邮寄东西,回武汉,找房子等,中间大概用了一个多星期。


在 8 月 18 号终于又有了一个自研的面试, 15:40 腾讯会议线上一面 - 技术面,上海那边的开发负责面试,问了一些问题,比较普通,我现在毫无印象。


一面过了,在 8 月 22 日,13:00 二面(现场面),公司办公地点在武昌火车站地铁口,刚开始觉得还不错,但一进去,一个开发都没有,就 1 个人,直接无语...... 武汉算是分部,那个人还不懂技术,和我吹了一下公司怎么怎么厉害,先是做了一份笔试题(比较基础)比如



  • 3 种方式实现顶部导航+左侧菜单+右侧主内容区域布局

  • jwt 鉴权逻辑

  • vue 数组下标改值,响应式丢失、为什么


7-hangshu.png


然后那个人拍了我写的笔试题,让上海那边的人看,说是做的不错。再视频连线进行面试,大致问了一些基础问题,然后坑的地方来了。我之前待过的公司,一个一个问我离职原因。。。。。。


然后就是副总面,问我有没有做过异形屏的适配,有没有写过绘制、渲染逻辑,我。。。。。。然后又问了我好几个假大空的问题,我一脸懵逼,比如一个公司呆 8 年和 8 年每年换一家公司你觉得哪种好。


后面就是回去等消息了,然后就没有然后了。。。。。


某金融公司 - 外包 17k(过)



来源:BOSS,岗位:前端开发 - 外包 17k



和上面那个公司同一时间段,在 8 月 18 号也进行了这家公司的腾讯会议一面


一面比较简单,大致为了下工作经历,重点问了下低代码、怎么动态加载渲染一个组件,底层怎么实现?面试时间比较短,有点仓促


8 月 21 号二面,大致问了一些问题后,还是追问低代码方面的问题,组件级别、可以内嵌到其他指定页面的这种低代码 sdk 封装怎么做?他们是想招个会低代码,有过 sdk 封装经验的。我之前工作中有做过组件库,封装过百万用户级别的小程序 sdk、也做过功能引导、错误上报等 sdk,还自己实现过多个 npm 包轮子,算是勉强符合他们的要求。


二面过了后,开始谈薪资,17k,基本不加班,8 月 23 号三面笔试(类似走过场),有题库,刷一下就没问题,通过就发邮件 offer 了。


8-zhengquan.png


这家公司过了,但我没有接轻易接 offer,而是让 HR 等第二天中午我的反馈,我不想接了别人 offer 又不去。这家公司的 HR 比较好、很热心积极。


主要有以下几个原因



  1. 后面还有一个 18k 的也是同一天二面,且面试体验好,大概率过了,只等确定 offer。

  2. 这家比较远,在花山,而后面一家离我比较近

  3. 这家试用期打折,下面一家不打折。


最终拒了这家 offer,因为下面要讲的这家 offer 下来了,前方高能预警,后面这家公司巨坑、后悔拒了这家。。。。


某互联网公司 - 外包 18k(过)



来源:BOSS,岗位:前端开发(外包)18k



和上面那家几乎同一时间,这家公司也进行了两轮面试


一面,腾讯会议,从 3-4 个 UI 中,选一个题来实现,30 分钟,就是平常干活画 UI,难度不大,面试官是个声音好听的妹子。


二面,腾讯会议,结对编程,面试官出题,我描述实现,面试官写代码,包括



  1. 一个简单的需要使用 Promise 应用题

  2. 运行一个 vue 项目,vue2 写法改 vue3 写法,封装一个计时器组件,组件加 props,组件加插槽等


面试体验真的很好,18k offer 下来后,果断选择了这家离我近的公司。


9-offer-3.png


但没想到的是,入职第一天发现这家公司管理问题很大



  1. 开发环境差,只能用网页版的 vscode,除了要配置 host 外,还有配置端口映射,配置稍微有问题就运行不起来,体验较差。

  2. 沟通太依赖线上,武汉这边基本是xx一线城市那边的产品、UI、开发分配任务给这边开发,沟通成本非常高。

  3. 加班问题,说的是早 9 晚 6,但他们自研一般下班这个点可能会去吃个饭, 然后回来加班,git log 看了下提交记录,不少是 20:00 之后的,还有 21 点、22 点之后的.... 如果真融入这个团队,不加班我是不信的。


从面试体验、沟通来看,这里的开发人员是优秀的,但实际入职却发现环境、氛围差的情况,我只能把这种问题归纳到管理上了。


第一天基本没干活就是配置环境,但这个氛围,我真的接受不了,后面就果断放弃这家公司了。


武汉找工作经验总结


上面我大致描述了从 7 月初到 8 月底的简历投递、面试经历。主要是面试少,实际面试通过率为 60%。下面是一些总结



  • 投递简历时段最好是周一到周三上午 8-9 点,回复、面试机会较多,周五到周天基本没反应。

  • 武汉原理性问的不多,主要还是能干活,比较需要多面手,就是什么都会的,比如 WebGL, Three.js,uni-app 等

  • 一定要问清楚、开发环境、加班问题,不要不好意思,能找自研就尽量找自研。

  • 不要听 HR 或者面试官怎么说,而是自己通过行业、所做的业务去判断是否有坑。


完结撒花,如果觉得内容对您有帮助,那就点个免费的赞吧~~


另外最近有和我一样在找工作的小伙伴吗?你们有遇到过什么坑吗?欢迎在评论区讨论~~~


作者:dev_zuo
来源:juejin.cn/post/7275225948453568552
收起阅读 »

请给系统加个【消息中心】功能,因为真的很简单

个人项目:社交支付项目(小老板) 作者:三哥,j3code.cn 项目文档:http://www.yuque.com/g/j3code/dv… 预览地址(未开发完):admire.j3code.cn/small-boss 内网穿透部署,第一次访问比较慢 ...
继续阅读 »

个人项目:社交支付项目(小老板)


作者:三哥,j3code.cn


项目文档:http://www.yuque.com/g/j3code/dv…


预览地址(未开发完):admire.j3code.cn/small-boss



  • 内网穿透部署,第一次访问比较慢



我相信,打开一个带有社交类型的网站,你或多或少都可以看到如下的界面:


1)消息提示


Snipaste_2023-08-27_13-41-36.jpg


2)消息列表


这样


Snipaste_2023-08-27_13-42-25.jpg


这样


Snipaste_2023-08-27_16-41-30.jpg


那,这就是我们今天要聊的【消息中心】。


1、设计


老规矩先来搞清楚消息中心的需求,再来代码实现。


我们知道在社交类项目中,有很多评论、点赞等数据的产生,而如果这些数据的产生不能让用户感知到,那你们想想这会带来什么影响?



用户A:太鸡肋了,发布的内容被人评论点赞了,我居然看不到,下次不用了...


用户B:还好没用这个系统...



所以,看到这些结果我们是不是能够意识到一个健全的社交功能,是不是少不了这种通知用户的机制啊!而这种机制我就把他定义为【消息中心】功能。


再来拆分一下这四个字:消息中心



  1. 消息

  2. 中心


消息:这个可以是由我们自己定义,如:把帖子被用户评论当作一条消息,把评论被用户点赞也可以当作一条消息,甚至系统发布的通知也是一条消息。


中心:这个就是字面意思,将上面所提到的所有消息,归拢到一个地方进行展示。


上面我们也提到消息基本就是这两种:



  • 用户对用户:用户消息

  • 平台对用户:系统消息


针对用户消息,就类似这样,用户 A 给用户 B 的一条评论进行了点赞,那这个点赞动作就会产生一条消息,并且通知到用户 B 的一个存储消息的地方,这里通常就指用户的收件箱。这个收件箱就是专门用来存储用户发给用户的消息,而这个点对点的模式是不是就是推送模式啊!(A 推送消息给 B)


接着针对系统消息,就类似这样,平台管理人员发布了一条通知,告诉大家平台有啥 XXX 活动。那这个活动通知肯定是要让平台的所有用户都知道把,所以这个通知就要存在一个发件箱中。这个发件箱就是专门存储平台的通知,所有用户都来这个发件箱中读取消息就行,而这个一对多的模式是不是就是拉取模式啊!(所有用户都来拉取平台消息)


这样一来,我们根据不同的消息场景就抽出了一个基本的消息推拉模型,模型图如下:



Snipaste_2023-08-27_14-27-25.jpg



Snipaste_2023-08-27_14-59-50.jpg


针对这两种模式,不知道大家有没有看出区别,好像乍一看没啥区别,都是发消息,读消息,对吧!


没错,确实都是一个发,一个读,但是两者的读写频率确实有着巨大的差异。先来看推模型,一个普通用户发表了一条帖子,然后获得了寥寥无几的评论和赞,这好似也没啥特别之处,对吧!那如果这个普通用户发表的帖子成为了热门帖子呢,也即该贴子获得了上万的评论和赞。那,你们想想是不是发消息的频率非常高,而该普通用户肯定是不可能一下子读取这么多消息的,所以是不是一个写多读少的场景。再来看看拉模型,如果你的平台用户人数寥寥无几,那倒没啥特别之处,但如果用户人数几万甚至几十万。那,每个用户都过来拉取系统消息是不是就是一个读频率非常高,而发消息频率非常低(系统消息肯定不会发的很快),所以这是不是一个读多写少的场景。


1.1 推:写多读少


针对这个模式,我们肯定是要将写这个动作交给性能更高的中间件来处理,而不是 MySQL,所以此时我们的 RocketMQ 就出来了。


当系统中产生了评论、点赞类的高频消息,那就无脑的丢给 MQ 吧,让其在消息中间件中呆会,等待消费者慢慢的将消息进行消费并发到各个用户的收件箱中,就类似下面这张图的流程:


Snipaste_2023-08-27_15-45-46.jpg


2.2 拉:读多写少


那对于这个模式,所实话,我觉得不用引入啥就可以实现,因为对于读多的话无非就是一个查,MySQL 肯定是能搞定的,即使你的用户几万、几十万都是 ok 的。


但咱们是不是可以这样想一下,一个系统的官方通知肯定是不多的,或者说几天或者几个星期一次,且一旦发送就不可更改。那是不是可以考虑缓存,让用户读取官方通知的时候走缓存,如果缓存没有再走 MySQL 这样应该是可以提高查询效率,提高响应速度。


具体流程如下图:


Snipaste_2023-08-27_15-57-21.jpg


2.3 表结构设计


基本的业务流程已经分析的差不多了,现在可以把表字段抽一下了,先根据上面分析的,看看我们需要那些表:



  1. 用户收件箱表

  2. 系统发件箱表


看似好像就这两张表,但是应该还有第三张表:



  1. 用户读取系统消息记录表



我们看到页面是不是每次有一条新的消息都会有一个小标点记录新消息数量,而第三张表就是为了这个作用而设计的。


具体原理如下:



  1. 首先运营人员发布的消息都是存储在第二张表中,这肯定是没错的

  2. 那用户每次过来拉取系统消息时,将最近拉取的一条消息写入到第三种表中

  3. 这样等用户下次再来拉取的时候,就可以根据第三张表的读取记录,来确定他有几条系统消息未查看了


可能有人会发出疑问:那用户的收件箱为啥不出一个用户读取记录表呢!


这个很简单,因为收件箱中的数据已经表示这个用户需要都这些个消息了,只是不知道那些是已读的那些是未读的,我们只需要再收件箱表中加一个字段,这个字段的作用就是记录最新一次读取的消息 ID 就行,等下次要读消息时,找到上传读取读取消息的记录ID,往后读新消息即可。



好,现在来看看具体的表字段:


1)用户收件箱表(sb_user_inbox)



  • id

  • 消息数据唯一 id:MQ唯一消息凭证

  • 消息类型:评论消息或者点赞消息

  • 帖子id:业务id

  • 业务数据id:业务id

  • 内容:消息内容

  • 业务数据类型:业务数据类型(商品评论、帖子、帖子一级评论、帖子二级评论)

  • 发起方的用户ID:用户 A 对用户 B 进行点赞,那这就是用户 A 的ID

  • 接收方的用户ID:用户 B 的 ID

  • 用户最新读取位置ID:用户最近一次读取记录的 ID


SQL


CREATE TABLE `sb_user_inbox` (
`id` bigint(20) NOT NULL,
`uuid` varchar(128) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '消息数据唯一id',
`message_type` tinyint(1) NOT NULL COMMENT '消息类型',
`post_id` bigint(20) DEFAULT NULL COMMENT '帖子id',
`item_id` bigint(20) NOT NULL COMMENT '业务数据id',
`content` varchar(1000) COLLATE utf8mb4_german2_ci DEFAULT NULL COMMENT '内容',
`service_message_type` tinyint(1) NOT NULL COMMENT '业务数据类型',
`from_user_id` bigint(20) NOT NULL COMMENT '发起方的用户ID',
`to_user_id` bigint(20) NOT NULL COMMENT '接收方的用户ID',
`read_position_id` bigint(20) DEFAULT '0' COMMENT '用户最新读取位置ID',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `un01` (`uuid`),
UNIQUE KEY `un02` (`item_id`,`service_message_type`,`to_user_id`),
KEY `key` (`to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

可以看到,我加了很多业务相关的字段,这个主要是为了方便查询数据和展示数据。


2)系统发件箱表(sb_sys_outbox)



  • id

  • 内容


SQL


CREATE TABLE `sb_sys_outbox` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`content` varchar(2000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

这个表就非常简单了,没啥业务字段冗余。


3)用户读取系统消息记录表(sb_user_read_sys_outbox)



  • id

  • 系统收件箱数据读取id

  • 读取的用户id


SQL


CREATE TABLE `sb_user_read_sys_outbox` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`sys_outbox_id` bigint(20) NOT NULL COMMENT '系统收件箱数据读取id',
`user_id` bigint(20) NOT NULL COMMENT '读取的用户id',
PRIMARY KEY (`id`),
UNIQUE KEY `un` (`user_id`),
KEY `key` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

ok,这是消息中心所有分析阶段了,下面就开始实操。


2、实现


先来引入引入一下 RocketMQ 的依赖


<!--rocketmq-->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.1</version>
</dependency>

RocketMQ 的双主双从同步刷新集群搭建教程:blog.csdn.net/qq_40399646…


MQ 配置:


Snipaste_2023-08-27_16-26-09.jpg


2.1 生产者


先来实现生产者如何发送消息。


1)消息体对象:LikeAndCommentMessageDTO


位置:cn.j3code.config.dto.mq


@Data
public class LikeAndCommentMessageDTO {

/**
* 该消息的唯一id
* 业务方可以不设置,如果为空,代码会自动填充
*/

private String uuid;

/**
* 消息类型
*/

private UserCenterMessageTypeEnum messageType;

/**
* 冗余一个帖子id进来
*/

private Long postId;

/**
* 业务数据id
*/

private Long itemId;

/**
* 如果是评论消息,这个内容就是评论的内容
*/

private String content;

/**
* 业务数据类型
*/

private UserCenterServiceMessageTypeEnum serviceMessageType;

/**
* 发起方的用户ID
*/

private Long fromUserId;

/**
* 接收方的用户ID
*/

private Long toUserId;


/*
例子:
用户 A 发表了一个帖子,B 对这个帖子进行了点赞,那这个实体如下:
messageType = UserCenterMessageTypeEnum.LIKE
itemId = 帖子ID(对评论进行点赞,就是评论id,对评论进行回复,就是刚刚评论的id)
serviceMessageType = UserCenterServiceMessageTypeEnum.POST(这个就是说明 itemId 的 ID 是归于那个业务的,方便后续查询业务数据)
fromUserId = 用户B的ID
toUserId = 用户 A 的ID
*/

}

2)发送消息代码


位置:cn.j3code.community.mq.producer


@Slf4j
@Component
@AllArgsConstructor
public class LikeAndCommentMessageProducer {

private final RocketMQTemplate rocketMQTemplate;

/**
* 单个消息发送
*
* @param dto
*/

public void send(LikeAndCommentMessageDTO dto) {
if (Objects.isNull(dto.getUuid())) {
dto.setUuid(IdUtil.simpleUUID());
}
checkMessageDTO(dto);
Message<LikeAndCommentMessageDTO> message = MessageBuilder
.withPayload(dto)
.build();
rocketMQTemplate.send(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, message);
}

/**
* 批量消息发送
*
* @param dtos
*/

public void send(List<LikeAndCommentMessageDTO> dtos) {
/**
* 将 dtos 集合分割成 1MB 大小的集合
* MQ 批量推送的消息大小最大 1MB 左右
*/

ListSizeSplitUtil.split(1 * 1024 * 1024L, dtos).forEach(items -> {
List<Message<LikeAndCommentMessageDTO>> messageList = new ArrayList<>(items.size());
items.forEach(dto -> {
if (Objects.isNull(dto.getUuid())) {
dto.setUuid(IdUtil.simpleUUID());
}
checkMessageDTO(dto);
Message<LikeAndCommentMessageDTO> message = MessageBuilder
.withPayload(dto)
.build();
messageList.add(message);
});
rocketMQTemplate.syncSend(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, messageList);
});
}

private void checkMessageDTO(LikeAndCommentMessageDTO dto) {
AssertUtil.isTrue(Objects.isNull(dto.getMessageType()), "消息类型不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getItemId()), "业务数据ID不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getServiceMessageType()), "业务数据类型不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getFromUserId()), "发起方用户ID不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getToUserId()), "接收方用户ID不为空!");
}


/**
* 发送点赞消息
*
* @param messageType 消息类型
* @param serviceMessageType 业务类型
* @param itemToUserIdMap 业务ID对应的用户id
* @param saveLikeList 点赞数据
*/

public void sendLikeMQMessage(
UserCenterMessageTypeEnum messageType,
UserCenterServiceMessageTypeEnum serviceMessageType,
Map<Long, Long> itemToUserIdMap, List<Like> saveLikeList)
{
if (CollectionUtils.isEmpty(saveLikeList)) {
return;
}
List<LikeAndCommentMessageDTO> dtos = new ArrayList<>();
for (Like like : saveLikeList) {
LikeAndCommentMessageDTO messageDTO = new LikeAndCommentMessageDTO();
messageDTO.setItemId(like.getItemId());
messageDTO.setMessageType(messageType);
messageDTO.setServiceMessageType(serviceMessageType);
messageDTO.setFromUserId(like.getUserId());
messageDTO.setToUserId(itemToUserIdMap.get(like.getItemId()));
dtos.add(messageDTO);
}
try {
send(dtos);
} catch (Exception e) {
//错误处理
log.error("发送MQ消息失败!", e);
}
}
}

注意:这里我用了 MQ 批量发送消息的一个功能,但是他有一个限制就是每次只能发送 1MB 大小的数据。所以我需要做一个功能工具类将业务方丢过来的批量数据进行分割。


工具类:ListSizeSplitUtil


位置:cn.j3code.config.util


public class ListSizeSplitUtil {

private static Long maxByteSize;

/**
* 根据传进来的 byte 大小限制,将 list 分割成对应大小的 list 集合数据
*
* @param byteSize 每个 list 数据最大大小
* @param list 待分割集合
* @param <T>
* @return
*/

public static <T> List<List<T>> split(Long byteSize, List<T> list) {
if (Objects.isNull(list) || list.size() == 0) {
return new ArrayList<>();
}

if (byteSize <= 100) {
throw new RuntimeException("参数 byteSize 值不小于 100 bytes!");
}
ListSizeSplitUtil.maxByteSize = byteSize;


if (isSurpass(List.of(list.get(0)))) {
throw new RuntimeException("List 中,单个对象都大于 byteSize 的值,分割失败");
}

List<List<T>> result = new ArrayList<>();

List<T> itemList = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
itemList.add(list.get(i));

if (isSurpass(itemList)) {
i = i - 1;
itemList.remove(itemList.size() - 1);
result.add(new ArrayList<>(itemList));
itemList = new ArrayList<>();
}
}
result.add(new ArrayList<>(itemList));
return result;
}


private static <T> Boolean isSurpass(List<T> obj) {
// 字节(byte)
long objSize = RamUsageEstimator.sizeOfAll(obj.toArray());
return objSize >= ListSizeSplitUtil.maxByteSize;
}
}

至此呢,生产者的逻辑就算是完成了,每次有消息的时候就调用这个方法即可。


2.2 消费者


位置:cn.j3code.user.mq.consumer


@Slf4j
@Component
@AllArgsConstructor
@RocketMQMessageListener(topic = RocketMQConstants.USER_MESSAGE_CENTER_TOPIC,
consumerGroup = RocketMQConstants.GROUP,
messageModel = MessageModel.CLUSTERING,
consumeMode = ConsumeMode.CONCURRENTLY
)

public class LikeAndCommentMessageConsumer implements RocketMQListener<LikeAndCommentMessageDTO> {

private final UserInboxService userInboxService;

@Override
public void onMessage(LikeAndCommentMessageDTO message) {
userInboxService.saveMessage(message);
}
}

saveMessage 方法的逻辑就是将消息保存到 MySQL 中,至此消息的产生和存储就算完成了,下面来看看用户如何查看吧!


2.3 用户消息查看


对于用户查看普通的消息就是访问一下 MySQL,并且更新一下最新读取的字段值即可,我贴一下关键代码就行了,代码如下:


public IPage<UserMessageVO> page(UserMessagePageRequest request) {
// 获取消息
IPage<UserMessageVO> page = getBaseMapper().page(new Page<UserMessageVO>(request.getCurrent(), request.getSize()), request);

if (CollectionUtils.isEmpty(page.getRecords())) {
return page;
}
// 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
if (request.getCurrent() == 1) {
if (Objects.isNull(page.getRecords().get(0).getReadPositionId()) ||
page.getRecords().get(0).getReadPositionId() == 0) {
UserInbox userInbox = new UserInbox();
userInbox.setId(page.getRecords().get(0).getId());
userInbox.setReadPositionId(userInbox.getId());
updateById(userInbox);
}
}
return page;
}

2.4 系统消息查看


对于系统消息的查看也是,只贴出关键代码,查询和更新读取记录逻辑,代码如下:


@Override
public IPage<SysOutboxVO> lookSysPage(SysOutboxPageRequest request) {
Page<SysOutbox> page = lambdaQuery()
.orderByDesc(SysOutbox::getId)
.page(new Page<>(request.getCurrent(), request.getSize()));
IPage<SysOutboxVO> outboxVOIPage = page.convert(userInboxConverter::converter);
if (CollectionUtils.isEmpty(outboxVOIPage.getRecords())) {
return outboxVOIPage;
}
// 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
if (request.getCurrent() == 1) {
userReadSysOutboxService.updateReadLog(page.getRecords().get(0).getId(), SecurityUtil.getUserId());
}
return outboxVOIPage;
}

这里,可能有人会发现,没有按照上面分析的那用从缓存中读,是的。这里的实现我没有用到 Redis,这里我偷了一下懒,如果有拿到我代码的同学可以试着优化一下这个逻辑。


作者:J3code
来源:juejin.cn/post/7274922643453853735
收起阅读 »

血压飙升!记一次关于手机号存储的前后端讨论

web
本文是为了探讨技术架构管理的,不要带入实际生活 起 事情是这样的,有一款产品需要实现国际化的注册需求,界面如下 : 涉及到的手机号码,因为要区分国家区号,所以和手机号是分开的,前端形态是分开存储的。一开始数据确实也是分开存的,么的问题。 本来是很简单的表单...
继续阅读 »

本文是为了探讨技术架构管理的,不要带入实际生活




事情是这样的,有一款产品需要实现国际化的注册需求,界面如下 :


image.png


涉及到的手机号码,因为要区分国家区号,所以和手机号是分开的,前端形态是分开存储的。一开始数据确实也是分开存的,么的问题。


本来是很简单的表单需求,结果出了幺蛾子。



对于前端来说,这就是两个字段,在表单传值和数据逻辑处理时,直接用两个字段无疑是最直接和简单的:


const formData = {
country_code: '86',
phone: '13345431234'
...
}

但是,产品侧有一个强需求:手机号和其他的两个字段要做唯一性校验,不但要求三个至少存在一个,而且还要查别的业务库的数据表来校验这个注册人员的唯一性,有一套复杂的逻辑。总结一下就是,后端分开两个字段来判断很麻烦,这个也好理解,有耦合的逻辑重构要成本的。所以后端是这么要求数据的:


// (86)13345431234
phone: `(${country_code})${phone}`

将国家码放在前缀括号里拼接为一个字符串。这样一来,前端的成本就相对高了一些,不但在表单提交时要拼接,而且在列表查询时,要按照括号匹配解析出前缀来动态显示


const regex = /^\((\d+)\)(\d+)$/;
const matches = phoneNumber.match(regex);
// 如果匹配成功,返回国家码和号码的数组
if (matches) {
const countrycode = matches[1];
const number = matches[2];
return [countrycode, number];
}

就算这样,也可以接受,之前也不止一次挖过这种自己拼接自己解析的坑了。但是随后就有点让人血压上升了。



由于业务扩展,上面的手机号,要传递给一款即时通讯软件 xxx 的 API 来做下发,下发侧则规定了,需要不带括号但是要有国家区号前缀的手机号码


// 8613345431234
phone: `${country_code}${phone}`

这就有问题了,我们直接对接的后端需要括号的,下发侧对接的后端同学不需要括号。


第一阶段


血压上升 20%


讨论让后端换成两个字段存储,这样更灵活,方便拼接;或者给下发侧发数据时去掉括号也可以。后端很为难:”唯一性判断还有其他的逻辑很复杂,有耦合性,拆开字段就要重构,工期又紧,刚完成就要改,误了工期只有我背锅;去掉括号不是不可以,但地方有点多,还要考虑批量数据的性能,而且已经进入测试了,不接受临时需求“。我一想有道理啊,就去问下发侧同学。


第二阶段


血压上升 60%


问下发侧对接的同学,可以处理后再给 API下发吗?果断拒绝。他们也有理由的:”按照软件设计的规范,我们不应该非标处理这个问题的,下发的规范理应就是我们接口的规范,因为规范还有很多,这个地方妥协了,以后肯定还会有更多的非标处理,我们的代码就乱了。“


我草,我一想之前那那么多屎山的由来,觉得更有理了,那怎么办?弄得好像前端这里没有理了。


第三阶段


🔥 血压上升 120% 🔥


下发侧对接的同学反过来给我们前端提了建议,让我们在注册的时候,把前端两个字段就直接不用括号来拼接给后端,在需要解析的时候,使用特别手段解析出国家码。还好心的给我们搜索了一个解析手机号的 js 库:


企业微信截图_1bfc9849-e9f2-4289-a4cb-01098e3dcf2e.png


我只能说,我 TM 谢谢你。😭


前端莫名其妙的凭空多了需求,而且加个库为了给后端擦屁股,还增加了打包体积,出了问题我们得首先背锅,这肯定不行。



这起事件牵涉的技术问题并没有多难,甚至有些幼稚,但是却蕴含了不少上班族哲学,最主要的一点是:如何学会背锅的问题。锅不是不能背,但是背锅换来的应该是产品成功后更多的功劳回报,而不是这种暗箱操作,老板压根不知道,只有扛不住的人吃暗亏。


个人觉得,这个应该是架构师或者技术总监的任务,提前了解上下游业务,并确定好字段与数据结构,而不是等用的时候才发现数据对不上。规划好用什么方案来开发。并将方案设计交于老板开会讨论得出定稿。之后再分派人员开发,就不会有这个事件了。



类似的问题还有很多,前后端与下游计算价格进制与单位不统一,每次都要非标转换;



最后的结局,产品出面调停,软磨硬泡,最终后端妥协了,夹在前端和下发侧之间, 确实为难。针对前端还是原来的逻辑不动,使用括号存储,他在下发侧拉取数据的时候做转换,去掉括号。其中涉及的性能问题和开发成本也申请了延长工期。


Happy Ending !!😁


血压恢复 0%


39df05d4-0146-4fbc-9eda-384298424f19.jpg



方案设计注意事项:



  • 注意产品迁移的问题。同类型的产品,如果针对的客户群体不同,其表现形态可能完全不一样,产品迁移过程中,要提前分析出新产品的业务场景的差异,做好风险分析。

  • 提前确定产品上下游生态,做好风险管控。比如下发侧有 API 对接的,需熟悉其 API 文档与使用限制,并在服务不可用时设计告警方案等。


作者:小肚肚肚肚肚哦
来源:juejin.cn/post/7275576074589880372
收起阅读 »

为什么日本的网站看起来如此不同

web
该篇文章讨论了日本网站外观与设计的独特之处。作者指出日本网站设计与西方设计存在明显差异。文章首先强调了日本网站的视觉风格,包括丰富的色彩、可爱的角色和复杂的排版。作者解释了这种风格背后的文化和历史因素,包括日本的印刷传统和动漫文化。文章还讨论了日本网站的信息密...
继续阅读 »

该篇文章讨论了日本网站外观与设计的独特之处。作者指出日本网站设计与西方设计存在明显差异。文章首先强调了日本网站的视觉风格,包括丰富的色彩、可爱的角色和复杂的排版。作者解释了这种风格背后的文化和历史因素,包括日本的印刷传统和动漫文化。
文章还讨论了日本网站的信息密集型布局,这种布局适应了日本语言的特点,使得页面能够容纳大量文字和图像。此外,文章提到了日本网站的功能丰富性,如弹出式窗口和互动元素,以及这些元素在用户体验方面的作用。
作者强调了日本网站在技术和创新方面的进步,尽管在过去存在技术限制。最后,文章提出了一些关于如何将日本网站设计的元素应用到其他文化中的建议。


下面是正文~~~


多年来,我朋友与日本的网站有过许多接触——无论是研究签证要求、计划旅行,还是简单地在线订购东西。而我花了很长时间才适应这些网站上的大段文字、大量使用鲜艳颜色和10多种不同字体的设计,这些网站就像是直接冲着你扔过来的。


image.png


虽然有许多网站都采用了更简约、易于导航的设计,适应了西方网站的用户,但是值得探究的是为什么这种更复杂的风格在日本仍然盛行。


只是为了明确起见,这些不是过去的遗迹,而是维护的网站,许多情况下,它们最后一次更新是在2023年。


image.png


我们可以从几个角度来分析这种设计方法:



  • 字体和前端网站开发限制

  • 技术发展与停滞

  • 机构数字素养(或其缺乏)

  • 文化影响


与大多数话题一样,很可能没有一个正确的答案,而是这个网站设计是随着时间的推移而相互作用的各种因素的结果。


字体和前端网站开发限制


对于会一些基本排版知识、掌握适当软件并有一些空闲时间的人来说,为罗马化语言创造新字体可能是一项有趣的挑战。然而,对于日语来说,这是一个完全不同层次的努力。


要从头开始创建英文字体,需要大约230个字形——字形是给定字母的单个表示(A a a算作3个字形)——或者如果想覆盖所有基于拉丁字母表的语言,则需要840个字形。对于日语而言,由于其三种不同的书写系统和无数的汉字,需要7,000至16,000个字形甚至更多。因此,在日语中创建新字体需要有组织的团队合作和比其拉丁字母表的同行们更多的时间。



这并不令人意外,因此中文和(汉字)韩文字体也面临着类似的工作量,这导致这些语言通常被称为CJK字体所覆盖。



由于越来越少的设计师面对这个特殊的挑战,建立网站时可供选择的字体也越来越少。再加上缺乏大写字母和使用日文字体会导致加载时间较长,因为需要引用更大的库,这就不得不采用其他方式来创建视觉层次。


以美国和日本版的星巴克主页为例:


美国的:


image.png


日本的


image.png


就这样,我们就可以解释为什么许多日本网站倾向于用文字较多的图片来表示内容类别了。有时,你甚至会看到每个磁贴都使用自己定制的字体,尤其是在限时优惠的情况下。


image.png


技术发展/停滞与机构数字素养


如果你对日本感兴趣,你可能对现代与过时技术之间的鲜明对比有所了解。在许多地方,先进的技术与完全过时的技术并存。作为世界机器人领导者之一的国家,在台场人工岛上放置了一座真人大小的高达雕像,却仍然依赖软盘和传真机,面对2022年Windows资源管理器关闭时感到恐慌。


image.png


在德国,前总理安格拉·默克尔在2013年称互联网为“未知领域”后,遭到全国范围的嘲笑。然而,这在2018年被前网络安全部长樱田义孝轻易地超越,他声称自己从未使用过电脑,并且在议会被问及USB驱动器的概念时,他被引述为“困惑不解”(来源)。


对于那些尚未有机会窥探幕后幻象的人来说,这可能听起来很奇怪,但日本在技术素养方面严重落后于更新计划。因此,可以推断这些问题也在阻碍日本网站设计的发展。而具体来说,日本的网页设计正面临着这一挑战——只需在谷歌或Pinterest上搜索日本海报设计,就能看到一个非常不同和现代化的平面设计水平。


image.png


文化影响


在分析任何设计选择时,不应低估文化习俗、倾向、偏见和偏好的影响。然而,“这是文化”的说法可能过于简单化,并被用作为各种差异辩解的借口。而且,摆脱自己的观点偏见是困难的,甚至可能无法完全实现。


因此,从我们的角度来看,看这个网站很容易..


image.png


感觉不知所措,认为设计糟糕,然后就此打住。因为谁会使用这个混乱不堪的网站呢?


这就是因为无知而导致有趣的见解被忽视的地方。现在,我没有资格告诉你日本文化如何影响了这种设计。然而,我很幸运能够从与日本本土人士的交谈中获得启发,以及在日本工作和生活的经验。


与这个分析相关的一次对话实际上不是关于网站,而是关于YouTube的缩略图 - 有时候它们也同样令人不知所措。


image.png


对于习惯了许多西方频道所采用的极简和时尚设计——只有一个标题、重复的色彩搭配和有限的字体——上面的缩略图确实有些难以接受。然而,当我询问一个日本本土人士为什么许多极受欢迎频道的缩略图都是这样设计时,他对这种设计被视为令人困惑的想法感到惊讶。他认为日本的设计方法使视频看起来更加引人入胜,提供了一些信息碎片,从而使我们更容易做出是否有趣的明智决策。相比之下,我给他看的英文视频缩略图在他看来非常模糊和无聊。


也许正是这种寻求信息的态度导致了我们的观念如此不同。在日本,对风险的回避、反复核对和对迅速做出决策的犹豫明显高于西方国家。这与更加集体主义的社会心态紧密相连——例如,在将文件发送给商业伙伴之前进行两次(或三次)检查可能需要更长时间,但错误的风险显著降低,从而避免了任何参与者丢面子的情况发生。


尽管有人认为这只适用于足够高的赌注,而迷惑外国游客似乎不符合条件——搜索一下“Engrish”这个词,然后感谢我吧。


回到网站设计,这种文化角度有助于解释为什么在线购物、新闻和政府网站在外部观察者看来常常是“最糟糕的罪犯”。毕竟,这些正是需要大量细节直接对应于做出良好购买决策、高效地保持最新信息或确保你拥有某个特定程序的所有必要信息的情况。


有趣的是,关于美国人和中国/日本人如何感知信息,也有相当多的研究。几项研究的结果似乎表明,例如,日本人更加整体地感知信息,而美国人倾向于选择一个焦点来引导他们的注意力(来源)。这可能给我们提供了另一个线索,解释为什么即使在日语能力较高的情况下,西方人对这类网站也感到困难。


后但并非最不重要的是,必须说的是,网站并不是在一个在线真空中存在。而且,各种媒体,从小册子或杂志到地铁广告,也使用了尽可能多地压缩信息的布局,人们可能已经习惯了这种无处不在的方式,以至于没有人曾经想过质疑它。


长话短说,这并不是为了找到标题问题的绝对答案,也不是为了加强日本人独特性的观点,就像日本人论一样。相反,尤其是在看到了几次关注一个解释为“真正答案”的讨论之后,我想展示科技、历史和文化影响的广度,这些最终塑造了这种差异。


作者:王大冶
来源:juejin.cn/post/7272290608655941651
收起阅读 »

从《孤注一掷》出发,聊聊 SSL 证书的重要性

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。 图片来源于电影《孤注一掷》 这部电影除了让人后背发凉外,也...
继续阅读 »

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。
image001.png
图片来源于电影《孤注一掷》


这部电影除了让人后背发凉外,也不禁让人回忆起了曾经上网冲浪遇到的种种现象:看小说时性感荷官总在网页右下角在线发牌;看电影时网页左下角常常蹦出“在线老虎机”……这些让人烦不胜烦的广告弹窗之所以出现,要么是建站人员利欲熏心投放了非法广告,要么就是因为网站使用了不安全的 HTTP 协议而遭到了攻击,正常的网页内容被恶意篡改。


网站是电信诈骗、网络赌博等非法内容出现的重灾区,建站者和使用者都应该提高安全意识,特别是对建站者来说,保护通信安全才能更好的承担起建站责任。本文将从 HTTP 讲起,介绍 HTTPS 保护通信安全的原理,以及作为网络通信安全基石的 SSL 证书的重要性。


HTTP 协议


HTTP(Hyper Text Transfer Protocol)协议是超文本传输协议。它是从 WEB 服务器传输超文本标记语言(HTML)到本地浏览器的传送协议。HTTP 基于 TCP/IP 通信协议来传递数据,通信双方在 TCP 握手后即可开始互相传输 HTTP 数据包。具体过程如下图所示:
image003.jpg
HTTP 建立流程


HTTP 协议中,请求和响应均以明文传输。如下图所示,在访问一个使用 HTTP 协议的网站时,通过抓包软件可以看到网站 HTTP 响应包中的完整 HTML 内容。


image005.png


虽然 HTTP 明文传输的机制在性能上带来了优势,但同时也引入了安全问题:



  • 缺少数据机密性保护。HTTP 数据包内容以明文传输,攻击者可以轻松窃取会话内容。

  • 缺少数据完整性校验。通信内容以明文传输,数据内容可被攻击者轻易篡改,且双方缺少校验手段。

  • 缺少身份验证环节。攻击者可冒充通信对象,拦截真实的 HTTP 会话。


HTTP 劫持


作为划时代的互联网通信标准之一,HTTP 协议的出现为互联网的普及做出了不可磨灭的贡献。但正如上节谈到, HTTP 协议因为缺少加密、身份验证的过程导致很可能被恶意攻击,针对 HTTP 协议最常见的攻击就是 HTTP 劫持。


HTTP 劫持是一种典型的中间人攻击。HTTP 劫持是在使用者与其目的网络服务所建立的数据通道中,监视特定数据信息,当满足设定的条件时,就会在正常的数据流中插入精心设计的网络数据报文,目的是让用户端程序解析“错误”的数据,并以弹出新窗口的形式在使用者界面展示宣传性广告或直接显示某网站的内容。


下图是一种典型的 HTTP 劫持的流程。当客户端给服务端发送 HTTP 请求,图中发送请求为“梁安娜的电话号码是?”,恶意节点监听到该请求后将其放行给服务端,服务端返回正常 HTML 响应,关键返回内容本应该是“+86 130****1234”,恶意节点监听到该响应,并将关键返回内容篡改为泰国区电话“+66 6160 *88”,导致用户端程序展示出错误信息,这就是 HTTP 劫持的全流程。


image007.jpg
HTTP 劫持流程


例如,在某网站阅读某网络小说时,由于该网站使用了不安全的 HTTP 协议,攻击者可以篡改 HTTP 相应的内容,使网页上出现与原响应内容无关的广告,引导用户点击,可能将跳转进入网络诈骗或其他非法内容的页面。


image009.png
原网页


image011.png
HTTP 劫持后网页


HTTPS 工作原理


HTTPS 协议的提出正是为了解决 HTTP 带来的安全问题。HTTPS 协议(HyperText Transfer Protocol Secure,超文本传输安全协议),是一种通过计算机网络进行安全通信的传输协议。HTTPS 经由 HTTP 进行通信,但利用 SSL/TLS 来加密数据包。HTTPS 的开发主要是提供对网站服务器的身份认证,保护交换资料的隐私性与完整性。


TLS 握手是 HTTPS 工作原理的安全基础部分。TLS 传统的 RSA 握手流程如下所示:


image013.jpg
TLS 握手流程


TLS 握手流程主要可以分为以下四个部分:


第一次握手:客户端发送 Client Hello 消息。该消息包含:客户端支持的 SSL/TLS 协议版本(如 TLS v1.2 );用于后续生成会话密钥的客户端随机数 random_1;客户端支持的密码套件列表。


第二次握手:服务端收到 Client Hello 消息后,保存随机数 random_1,生成随机数 random_2,并发送以下消息。



  • 发送 Server Hello 消息。该消息包含:服务端确认的 SSL/TLS 协议版本(如果双方支持的版本不同,则关闭加密通信);用于后续生成会话密钥的服务端随机数 random_2;服务端确认使用的密码套件

  • 发送“Server Certificate”消息。该消息包含:服务端的 SSL 证书。SSL 证书又包含服务端的公钥、身份等信息。

  • 发送“Server Hello Done”消息。该消息表明 ServerHello 及其相关消息的结束。发送这个消息之后,服务端将会等待客户端发过来的响应。


第三次握手:客户端收到服务端证书后,首先验证服务端证书的正确性,校验服务端身份。若证书合法,客户端生成预主密钥,之后客户端根据(random_1, random_2, 预主密钥)生成会话密钥,并发送以下消息。



  • 发送“Client Key Exchange”消息,该消息为客户端生成的预主密钥,预主密钥会被服务端证书中的公钥加密后发送。

  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。

  • 发送“Encrypted Handshake Message”消息,表示客户端的握手阶段已经结束。客户端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给服务端,供服务端校验。


第四次握手:服务端收到客户端的消息后,利用自己的服务端证书私钥解密出预主密钥,并根据(random_1, random_2, 预主密钥)计算出会话密钥,之后发送以下消息。



  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。

  • 发送“Encrypted Handshake Message”,表示服务端的握手阶段已经结束,同时服务端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给客户端,供客户端校验。


根据 TLS 握手流程,可以看出它是如何解决 HTTP 协议缺陷,以及避免中间人攻击的:


1.规避窃听风险,攻击者无法获知通信内容


在客户端进行真正的 HTTPS 请求前,客户端与服务端都已经拥有了本次会话中用于加密的对称密钥,后续双方 HTTPS 会话中的内容均用该对称密钥加密,攻击者在无法获得该对称密钥的情况下,无法解密获得会话中内容的明文。即使攻击者获得了 TLS 握手中双方发送的所有明文信息,也无法从这些信息中恢复对称密钥,这是由大数质因子分解难题和有限域上的离散对数难题保证的。


2.规避篡改风险,攻击者无法篡改通信内容


在数据通信阶段,双端消息发送时会对原始消息做一次哈希,得到该消息的摘要后,与加密内容一起发送。对端接受到消息后,使用协商出来的对称加密密钥解密数据包,得到原始消息;接着也做一次相同的哈希算法得到摘要,对比发送过来的消息摘要和计算出的消息摘要是否一致,可以判断通信数据是否被篡改。


3.规避冒充风险,攻击者无法冒充身份参与通信


在 TLS 握手流程中的第二步“Server Hello”中,服务端将自己的服务端证书交付给客户端。客户端拿到 SSL 证书后,会对服务端证书进行一系列校验。以浏览器为例,校验服务端证书的过程为:



  • 验证证书绑定域名与当前域名是否匹配。

  • 验证证书是否过期,是否被吊销。

  • 查找操作系统中已内置的受信任的证书发布机构 CA(操作系统会内置有限数量的可信 CA),与服务端证书中的颁发者 CA 比对,验证证书是否为合法机构颁发。如果服务端证书不是授信 CA 颁发的证书,则浏览器会提示服务端证书不可信。

  • 验证服务端证书的完整性,客户端在授信 CA 列表中找到服务端证书的上级证书,后使用授信上级证书的公钥验证服务端证书中的签名哈希值。

  • 在确认服务端证书是由国际授信 CA 签发,且完整性未被破坏后,客户端信任服务端证书,也就确认了服务端的正确身份。


SSL 证书


正如上一节介绍,SSL 证书在 HTTPS 协议中扮演着至关重要的作用,即验证服务端身份,协助对称密钥协商。只有配置了 SSL 证书的网站才可以开启 HTTPS 协议。在浏览器中,使用 HTTP 的网站会被默认标记为“不安全”,而开启 HTTPS 的网站会显示表示安全的锁图标。


image015.png
使用 HTTP 协议的网站


image028.gif
使用 HTTPS 协议的网站


从保护范围、验证强度和适用类型出发, SSL 证书会被分成不同的类型。只有了解类型之间的区别,才能根据实际情况选择更适合的证书类型,保障通信传输安全。


从保护范围分,SSL 证书可以分为单域名证书、通配符证书、多域名证书。



  • 单域名证书:单域名证书只保护一个域名,这些域名形如 http://www.test.com 等。

  • 通配符证书:通配符证书可以保护基本域和无限的子域。通配符 SSL 证书的公用名中带有星号 ,其中,星号表示具有相同基本域的任何有效子域。例如,。test.com 的通配符证书可用于保护 a.test.com、 b.test.com……

  • 多域名证书:多域证书可用于保护多个域或子域。包括完全唯一的域和具有不同顶级域的子域(本地/内部域除外)的组合。


从验证强度和适用类型进一步区分,SSL 证书可以分为 DV、OV、EV 证书。



  • DV(Domain Validated):域名验证型。在颁发该类型证书时,CA 机构仅验证申请者对域名的所有权。CA 机构会通过检查 WHOIS、DNS 的特定记录来确认资格。一般来说,DV 证书适用于博客、个人网站等不需要任何私密信息的网站。

  • OV(Organization Validated):组织验证型。OV 证书的颁发除了要验证域名所有权外,CA 还会额外验证申请企业的详细信息(名称、类型、地址)等。一般来说,OV 证书适用于中级商业组织。

  • EV(Extended Validation):扩展验证型。EV 证书的颁发除了 CA 对 DV 和 OV 证书所采取的所有身份验证步骤之外,还需要审查商业组织是否在真实运营、其实际地址,并致电以验证申请者的就业情况。一般来说,EV 证书适用于顶级商业组织。


结尾


随着互联网应用的普及,网络诈骗的方式也越发花样百出,让人防不胜防。


除了文内提到的网页环境,在软件应用、邮件、文档、物联网等领域同样存在恶意软件、钓鱼邮件、文档篡改、身份认证的问题。幸运的是,作为 PKI 体系下的优秀产品,证书体系同样在这些领域发挥着重要作用,软件签名证书、邮件签名证书、文档签名证书、私有证书等保护着各自领域的信息安全。


总有不法分子企图通过漏洞牟利,而证书体系在保护数据机密性、完整性、可用性以及身份验证场景上有着无可取代的地位,牢牢守护着用户信息,保障通信安全。


作者:火山引擎边缘云
来源:juejin.cn/post/7273685263841263672
收起阅读 »

任正非:我不懂技术、不懂管理、只懂分钱

01作为华为的创始人和独立股东,任正非的总出资占公司总股本的比例不足1%,其余全部由华为员工持有。其实在华为刚创立时,任正非就设计了员工持股制度,他的个人经历以及父母亲的影响让他意识到,要与员工分担责任、分享利益。只有“人人做老板,共同打天下”,当时“没有背景...
继续阅读 »

01

与生俱来的员工持股公司


从一家注册资金仅为2万元的销售代理企业成长为中国最大的民营企业,2021年在《财富》世界500强企业中排名第44位,华为的成功被人津津乐道,其中华为的“财散人聚”机制尤为引人注目。


企业家往往希望员工在既有工资水平下发挥最大价值,为企业创造财富。而华为不同,它舍得给员工分钱、舍得给员工股份,坚持“奉献者定当得到合理的回报,绝不会让‘雷锋’吃亏”。作为华为的创始人和独立股东,任正非的总出资占公司总股本的比例不足1%,其余全部由华为员工持有。


其实在华为刚创立时,任正非就设计了员工持股制度,他的个人经历以及父母亲的影响让他意识到,要与员工分担责任、分享利益。


只有“人人做老板,共同打天下”,当时“没有背景、没有资源、资金短缺”的华为才能吸引并留住人才,从而在与世界巨头和国企的竞争中脱颖而出。


华为初创时,任正非曾向学过经济学的父亲请教,得到了父亲的大力支持。父亲告诉他,民国年间的大掌柜和他的团队没有出钱也会参与分红,并让他仔细琢磨怎样利用好分红模式。


任正非听从父亲的建议,并且一直坚持下来,每年拿出大量的利润分给华为的奋斗者。正是这个让所有者和劳动者共享财富的机制,激发了华为全体员工持续奋斗的热情,为华为的发展壮大提供了强大的动力。任正非也承认“华为今天这么成功,与我不自私有一点关系”。



02

“财散人聚”的机制

为的“财散人聚”机制,把财富更多地分给干部和员工,把股权和能力、贡献和年功很好地结合起来,从而增加了企业的凝聚力、向心力与亲和力,提高了企业的创新力和竞争力。

今天,华为是一家100%由员工持有的民企。华为通过工会实行员工持股计划,参与人仅为公司员工,没有任何政府部门、机构持有华为股权。


1、员工持股计划


华为的员工持股计划始于1990年,当时华为刚起步不久,就面临着摩托罗拉、爱立信等世界巨头对市场的垄断,急需大量的人才和资金投入到技术产品研发、市场拓展中。


面临这样的发展难题,华为第一次提出内部融资、员工持股的概念,实行“工者有其股”,这就是华为员工持股计划的雏形。


华为最初的员工持股计划是员工以每股10元的价格购买公司股票,购入数量由员工的级别、绩效、贡献等确定,然后企业每年拿出税后利润的15%进行股权分红。


每个持股员工手中都有华为所发的股权证书,并盖有华为公司资金计划部的红色印章。股权分红为华为稳住了创业团队,吸引了不少人才,而且为了争取到购买资格,员工的工作积极性大大提高。


员工出资购买股票的方式在当时还为华为赢得了宝贵的发展资金,帮助华为走出了经济困境,为华为拓展市场、增加科研投入、获得竞争优势奠定了基础。



2、虚拟股票期权计划


1998年,华为高层在赴美考察期权激励和员工持股制度时,一种名为虚拟股票的激励制度引起了他们的注意。


虚拟股票是指公司授予激励对象一种虚拟的股票,激励对象可以据此享受一定数量的分红权和股票增值收益,但是这种股票不能转让和出售,在离开公司时自动失效。


2001年7月,华为股东大会通过了股票期权计划,推出了《华为技术有限公司虚拟股票期权计划暂行管理办法》,对员工不再配发1元/股的原始股票,而是以员工的责任和贡献为评判标准,发放以公司年末净资产折算价值的期权,让有贡献者都得到相应的回报。


持有虚拟股票的员工可以获得一定比例的分红以及虚拟股票对应的公司净资产增值部分,但是不能转让和出售,在离开华为时只能由华为出资回购。


虚拟股票所对应的公司净资产的增值部分,增值越多分红越多,这更是给华为员工注入了一剂“强心针”。


3、危机持股计划


2003年,华为业务受到“非典”影响,内忧外患下,华为实施了“危机持股计划”。华为近八成的员工都拥有公司股票的购买权,旨在通过大面积惠及股票购买权,向银行申请股权抵押的贷款额度,缓解资金紧张的问题,并且股权向核心员工倾斜,核心员工获得的配股额度远远大于普通员工,以稳定核心员工队伍,共渡难关。


员工持股制度从“普惠”向“重点激励”的转变,是因为有差距才能体现出知识、奋斗的价值,才能刺激员工艰苦奋斗,否则又会成为“大锅饭”,失去激励的作用。与以往不同,这次配股华为采取了“限制股+虚拟股”的模式。


往年积累的配股,即使员工不离开公司,也可以选择每年按一定比例兑现,但是华为对兑现比例进行了限制:一般员工每年兑现的比例最大不超过个人总股本的1/4,持股较多的核心员工每年可以兑现的比例则不超过个人总股本的1/10。


此次配股还规定了一个3年的锁定期,3年内不允许兑现、转让和抵押。若员工在3年之内离开公司,则所配的股票无效。通过“危机持股计划”,华为很好地稳定了核心员工队伍,实现了销售业绩和净利润的猛涨。


4、饱和配股制


随着老员工手中积累的持股数量越来越大,即使他们不奋斗,依然可以获得可观的分红,而且退休员工在离开公司后仍可选择继续持有股票;而新员工由于进公司时间短,持股数量有限,享受的分配比例反而不高。于是,新老员工的分享比例严重失衡。


了解决这一问题,华为2008年再次对员工持股制度进行了改革,开始实行饱和配股制。实行饱和配股制,即规定了员工的配股上限,不同工作级别匹配不同的持股数量,每个级别达到上限后,就不再参与新的配股。这一规定缩小了新老员工之间的收入差距,更有利于激励华为的新员工。


5、TUP:奖励期权计划


随着华为全球布局的不断深化,高薪聘请的海外员工越来越多,但是海外员工却无法参与华为的虚拟受限股。为了激励、留住海外员工,华为推出了时间单位计划,让海外员工也参与到利润分享中。


华为基于员工的历史贡献和未来发展前途,授予员工一定的TUP。获得TUP的员工在第一年不享受分红,第二年和第三年分别获取1/3额度、2/3额度的分红,第四年全额获取分红,第五年同时获得全额分红和TUP的增值收益,五年结束后TUP的权益清零,重新开始分配。


与员工持股制度不同的是,TUP不需要员工出资购买,因此TUP计划后来也用于激励刚进入华为的国内新员工。



03

利益分享,以奋斗者为本


什么叫奋斗?华为认为,为客户创造价值的任何微小活动,以及在劳动的准备过程中,为充实提高自己而做的努力,均叫奋斗,否则,再苦再累也不叫奋斗。


华为深知在通信行业,技术更替、产业变化迅速,竞争比传统产业更加激烈,要想在这场死亡竞赛中生存得更久,唯有奋斗。


那么,怎样才能使企业不断奋斗呢?这就要依靠坚持为华为奋斗的所有员工。因此,以奋斗者为本成为华为的核心价值观之一。


华为领导层懂得人心,更明白存天理,顺人欲的道理,不跟员工讲吃亏是福,而是十分肯定、强调员工奋斗的价值,从不吝啬对员工奋斗的奖励。


2012年年底,华为的一个竞标团队成功中标一个近10亿美元的大项目,攻克了某国多年未拿下的大粮仓,华为奖励了该团队700万元人民币。


没想到的是,在第二年成功签订合同后,华为领导层又提出再奖励该团队1000万元,他们认为,在一线奋斗的员工不容易,一定要给大家分好钱。最后,在该团队的推辞下,经过商议,华为将1000万元减为700万元,又奖励了一次。

在争夺人才上,华为也舍得花钱。为了从世界各地招收优秀大学生,让这些天才像泥鳅一样,钻活华为的组织,激活华为的队伍,华为为这些天才提供了优厚的年薪,有的甚至在读大二时就收到华为发出的录用通知。

任正非曾说自己不懂技术,不懂管理,只懂分钱,恰恰是这一分钱术,为华为解决了很多问题。华为的分配机制解决了价值创造、价值评价、价值分配等问题。最合理的分配机制是,谁创造价值,谁就享有价值。


作 者:宋志平,中国上市公司协会会长,中国企业改革与发展研究会会长,《共享机制》作者。

来 源:认识管理,本文摘编自《共享机制》,机械工业出版社出版。

收起阅读 »

使用 Vim 两年后的个人总结

为什么要使用 Vim 学习动机非常重要。并不是很多大牛程序员用 Vim 编程,你就应该去学习 Vim,如果你是这种心态,很大的概率,你会在几次尝试以后最终放弃,就像我曾经做过的一样。因为 Vim 的学习曲线很陡峭,没有强烈的学习动机很难坚持下来。 那我为什么后...
继续阅读 »

为什么要使用 Vim


学习动机非常重要。并不是很多大牛程序员用 Vim 编程,你就应该去学习 Vim,如果你是这种心态,很大的概率,你会在几次尝试以后最终放弃,就像我曾经做过的一样。因为 Vim 的学习曲线很陡峭,没有强烈的学习动机很难坚持下来。


那我为什么后来又重新开始学习 Vim,并在两年多后已经习惯、喜欢甚至离不开 Vim?原因很简单,我必须掌握 Vim。


我是一个很爱折腾的人,自己买过很多云服务器,也经常会在服务器上写一些程序,编辑器当然首选 Vim。日复一日,当有一天我实在无法忍受自己在服务端极其低效的编程体验后,我决定真正掌握 Vim。从那时候起,我开始刻意频繁练习,也终于有一天,我发现我完全存活了下来,并且喜欢上了 Vim。


我并不是说你一定要买个云服务器,然后在云服务器上写代码(其实现在你可以用 VSCode 的远程功能在服务器上写代码),我想表达的是,你一定要有足够的学习动机,这个学习动机往往来自于必要性,不管是工作上的必要性,还是自己业余项目上的必要性。也许,有强烈的炫耀动机可能也行。


当然了,当你真正喜欢上 Vim,你会有新的理解,比如 Vim 某种意义上代表了一些正向的价值,文章最后我会提到这一点。


关于 Normal 模式的最佳隐喻


《代码大全》(Code Complete)开头就讲了“软件构建的隐喻”,隐喻是非常好的方式,能够通过熟悉的事物帮我们建立正确的思维模型。关于 Vim 为什么要有 Normal 模式,我看过的最好的隐喻来自《Practical Vim》这本书,我摘录几个关键的段落:



Think of all of the things that painters do besides paint. They study their subject, adjust the lighting, and mix paints into new hues. And when it comes to applying paint to the canvas, who says they have to use brushes? A painter might switch to a palette knife to achieve a different texture or use a cotton swab to touch up the paint that's already been applied.




The painter does not rest with a brush on the canvas. And so it is with Vim. Normal mode is the natural resting state. The clue is in the name, really.




Just as painters spend a fraction of their time applying paint,programmers spend a fraction of their time composing code . More time is spent thinking, reading, and navigating from one part of a codebase to another. And when we do want to make a change, who says we have to switch to Insert mode? We can reformat existing code, duplicate it, move it around, or delete it. From Normal mode, we have many tools at our disposal.



作者把编程比喻成绘画,把 Normal 模式比喻成画家作画的间隙。就像画家要经常放下画笔,走远处看看,或用小刀、棉球等工具修改画作一样,程序员也不会一直输入代码(Insert 模式),程序员也需要思考,需要对程序做一些修改(不一定是插入内容),那么这个时候就应该进入 Normal 模式。Normal 模式让程序员休息、思考,同时提供了更多的工具,比如删除、复制、黏贴、跳转光标等等。每当写程序需要停顿思考的时候,就可以进入 Normal 模式。


一个最重要的模式


这里的模式,不是指“Normal”或“Insert”模式。而是我们在使用 Vim 组合快捷键时候的“操作模式”。这个最重要的模式如下:

Action = Operator + Motion

举一个例子,“删除当前到句尾的所有字符”的操作是d$d$ = d + $,其中的 d 即为 Operator,也即操作,$ 即为 Motion,也即操作的范围。这个模式在 Vim 中无处不在,再举一些例子:

  • dap,删除一整个段落;
  • yG,复制当前行到文件末尾所有内容;
  • cw, 修改当前单词(删除单词并进入 Insert 模式);

这是最基本的模式,也是 Vim 编辑器能高效编辑文本的基础,它把常用的 Operator 和 Motion 做了抽象,抽象成了一些简单字母,比如 d 代表删除操作,$代表句子末尾,而这些抽象符号又可以通过同一个公式组合使用,减轻了记忆负担。这是 Vim 非常优雅的地方。 不过有一个例外,如果你连续输入两个 Operator,就表示对当前行进行操作。比如 yy 表示复制当前行。


那 Vim 中有哪些常用的 Operator 呢,有以下这些:


至于 Motion,有更多,以下也是一些常用的:


当然还有更多,如果你感兴趣,可以在 Vim 的 Normal 模式下,输入以下命令查看完整的文档:

:h motion.txt

先存活下来


在成为 Vim 高手之前,我们的首要目标是先存活下来。这个目标其实并不难。


掌握基本的光标跳转,比如hlkj0^$ggG 等等,以及以上说的基本操作模式后,你大概率可以生存下来。当然知道不等于掌握,你需要频繁地练习把基本操作变成肌肉记忆。我一开始是跟着左耳朵耗子(在此纪念耗子叔)的文章《简明 VIM 练级攻略》练习,当时一旦有时间就打开文章,跟着内容逐条操作,一段时间后,我就真的存活下来了。


如果你也顺利存活了下来,在实际的开发过程中就已经可以使用 Vim 做一些编辑工作了,但可能总还是觉得哪儿哪儿不对劲,要完全行云流水还欠缺更多技巧。这个时候或许有必要去看看《Practival Vim》或者类似的书,更好地掌握 Vim 的设计理念以及许多细微的地方,同样配合不断的练习,我相信你迟早有一天会欣喜地发现自己在编码的时候几乎可以放弃鼠标了,这种喜悦或许类似于修仙小说中的破境。


恭喜你。


成为 Vim 高手的终极秘诀


其实没有秘诀。Vim 很快,但成为 Vim 高手是一个相对漫长的过程,在这个过程中你会掌握更多微妙的技能,比如如何更高效地使用 f{char} 命令更快地定位到某个字符。在生存下来以后,你唯一能做的就是每天使用 Vim。慢慢地, Vim 的使用会变成水和空气一样的自然存在,你从此离不开它。


如何每天使用 Vim 呢,以下是我的一些建议:

  • 把 Vim 变成日常开发工具。学习使用 Neovim,它提供了更好的插件和扩展机制,你如果愿意你甚至可以把 Neovim 配置成强大的 IDE。这里推荐一下掘金小册 Neovim 配置实战
  • 如果习惯使用 VSCode 或其他编辑器,可以安装相应的 Vim 插件
  • 如果你使用 Chrome 浏览器,你可以安装相应的 Vim 插件来提升浏览效率。
  • 平时习惯做笔记?那就使用一款支持 Vim 快捷键的笔记软件,比如我最喜欢的 Obsidian
  • 经常在 Cloud IDE 上写代码?建议使用一款支持 Vim 快捷键的 IDE,比如我常用的 Replit

总之,在我决定使用 Vim 提高编程效率以后,在任何编辑场景我都变得无法忍受没有 Vim 的存在,就是这么自然,它变成了我工作的一部分。Reddit 上有这样一条讨论,If using vim is a lifestyle/philosophy, what other products also fits into this lifestyle?,把 Vim 隐喻成一种生活方式/哲学确实很合适,Vim 和学习使用 Vim 隐含了一些有价值的东西,我相信大约有追求极致——更快更强的精神,坚持长期主义——忍受暂时痛苦,着眼长远的精神。或许也可以这么说,如果你有朝一日能成为 Vim 高手,你大概率也能做成其他许多困难的事。


少年们,加油。


作者:chaosflutter
链接:https://juejin.cn/post/7263676990736220220
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

晋升涨薪?不,晋升要命!

最近有个朋友来问我晋升的事,其实他没什么产出,只不过因为做的那块业务还不错,赚了好几个小目标,然后顺理成章的被提名了。所以他慌的要死! 你猜他来问什么内容?他什么都想问,又什么都问不出来,因为我也没晋升过。不过这并不妨碍我写点什么,谁让我会编呢? 我要不要提...
继续阅读 »

最近有个朋友来问我晋升的事,其实他没什么产出,只不过因为做的那块业务还不错,赚了好几个小目标,然后顺理成章的被提名了。所以他慌的要死!


你猜他来问什么内容?他什么都想问,又什么都问不出来,因为我也没晋升过。不过这并不妨碍我写点什么,谁让我会编呢?



我要不要提名晋升


我要不要提名晋升?你要是能问出这问题,要不刚毕业,要不加班加迷糊了!


晋升意味着涨薪,涨薪意味着每个月能多吃几顿海底捞,多看几部电影,这世道和谁过不去都不能和钱过不去。


但晋升往往是领导说了才算,除非你是向园,还有个董事长爷爷。所以,怎么说服他就成了一道槛。


工作三要素:A-能力;B-岗位;C-环境(其他人和事),而晋升基本只和 AB 有关。 果你在自己岗位上,已经承担了下一级该承担的责任。然后能力又达到了下一级所要求的水平,再不提名晋升就没天理了


例如我是P6,但是我一直在做P7的事情,同时在抗P7的责任,并且表现不错。那么我对标P7不就是既定事实嘛,既然是事实谁又能阻止你提名?


另外还有 C,如果把晋升与环境挂钩,晋升的理由变成了诸如  “如果我在他的位置上,我能做得比他更好”、“为什么他是P9而我是P8”,以这些理由提名晋升,属实是自寻烦恼,说不定明年你就成为人才输送给社会了。


从这个角度上看,我那位朋友已经晋升失败了。不过你们也不要太关注这个,缘分这个东西不是说有就有。就像你能恰好看见我这篇帖子,然后顺手点赞、收藏、在看一样



提名之后,如何准备答辩


晋升靠的是硬实力,以及10%的运气。 你想去吹牛也不是不行,就怕到时候下不来台。仅仅是 P6 升 P7 的答辩,上面坐着的都是P9级别的大佬和砖家。


PPT 以真实、简朴为主,凡是在 PPT 上花费超过10小时的,我觉得都有耍流氓的嫌疑。这些内容应该是这段时间你所积累的工作成果。平时没事拿个小笔记记一下,关键时刻它能像宋江一样救你的命。【推荐你用语雀,真的很好用】


有了 PPT,你得去讲出来吧。讲话作为一门艺术,对于我们理工科的同学还是有一定难度的,所以我建议你有空去参加下吐槽大会。没有条件?那就创造条件,公司里找几个段子手还不是轻而易举。相比之下,产品经理的优势比我们大多了。



如何把实力讲透?这里面是有一定技巧的,3分讲结果,7分讲过程。光讲结果不讲过程,30分钟的答辩,你5分钟就完成了,还是包含自我介绍的那种。


3分成效如何讲?——把我在当前岗位上,如何把手里的工作做上了一个新台阶 这种感觉讲出来,就是,因为你的努力而带来了什么改变?


7分过程如何讲?——把事情的复杂度、岗位的挑战、面临的困难讲清楚,把你做事的匠心讲清楚,你把你的做事的方法、思路讲清楚。说白了,就是“我解了一个挺难的题,我是这样那样解的”;


关于答辩与专家评委


评委扮演的角色很简单,评审的过程,就是评委向答辩人学习的过程。每个人都存在未涉及的领域,你看 ChatGPT 用了上亿的数据训练,花了几十亿美金,现在连小学数学题都解不出来


三个评委,花45分钟与答辩人进行深度交流,如果评委们都表示没有收获,学不到东西(无论是学到知识还是方法或者心态),那么答辩人晋升不通过,也不冤枉。


这个道理够简单吧,神雕侠侣里黄老邪为什么会和杨过拜把子,一方面是杨过的性格和黄老邪很像,另一方面是因为能从杨过那学到点东西。


回到现实,我们每个人都有直接的体会。如果有个大牛(至少他在当前的工作中是专业的)跟我们交流,我们一定有收获。如果对方十分平庸(或者在工作中能力一般),我们收获就比较少。


晋升通过,意味着什么


意味着加薪,年终奖多了点


意味着岗位(B)对你的能力(A)要求更大了一些


意味着你离 3.25 更近了一些,我知道的几个同事,每次晋升之后的第一个季度或半年度,都会拿一次3.25。


晋升不通过,意味着什么


恭喜你,终于松了口气!


作者:车辙cz
链接:https://juejin.cn/post/7244783947820449853
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS 开发中的AES加密

iOS
前言 在iOS的日常开发中,特别是设计网络请求时,会用到加密算法,例如在客户端需要发起一个HTTP请求给服务端,其中会传递一些参数,为了防止参数在网络传输过程中被窃取或者篡改,我们就需要使用一些加密算法来对请求参数加密和签名。今天就重点介绍一下AES和HMAC...
继续阅读 »

前言


在iOS的日常开发中,特别是设计网络请求时,会用到加密算法,例如在客户端需要发起一个HTTP请求给服务端,其中会传递一些参数,为了防止参数在网络传输过程中被窃取或者篡改,我们就需要使用一些加密算法来对请求参数加密和签名。今天就重点介绍一下AES和HMAC_SHA256两个算法,因为服务端大多数都是使用java语言来编写,AES算法在iOS的Objective-C中和java的实现有些差异,本文重点介绍AES在iOS开发中的应用和需要注意的事项。


AES 加密算法简介


AES是一种典型的对称加密/解密算法,使用加密函数和密钥来完成对明文的加密,然后使用相同的密钥和对应的函数来完成解密。AES的优点在于效率非常高,相比RSA要高得多。AES共有ECB、CBC、CFB和OFB四种加密模式。


在iOS中的实现


Objective-C中支持AES的ECB和CBC两种模式。
1、电码本模式(Electronic Codebook Book (ECB))
这种模式主要是将明文划分为几个明文段,分块加密,但是加密密钥是相同的。
2、密码分组链接模式(Cipher Block Chaining (CBC))
这种模式是先将明文切分成若干小段,然后每一小段与初始块或者上一段的密文段进行异或运算后,再与密钥进行加密。


ECB是最简单的一种模式,只需要传入待加密的内容和加密的key即可。(一般不推荐ECB模式)
CBC的特点是,除了需要传入加密的内容和加密的key,还需要传入初始化向量iv。即使每次加密的内容和加密的key相同,只要调整iv就可以让最终生成的密文不同。
在客户端和服务端之间传输数据一般是使用约定好的key对指定参数做AES的CBC加密,初始化向量可以随机动态生成,最终将生成好的密文和随机向量iv拼接在一起传给服务端。如:iv+密文。
iv是指定的长度如16位,这样服务端拿到客户端传输过来的数据可以先取前16位作为iv,剩余的是需要解析的密文。这么做大大提升了数据的安全性和破解难度。即使相同的带加密参数,因为有随机向量的参入,最终生成的密文也不相同。


iOS中一般使用#import <CommonCrypto/CommonCryptor.h>库中的这个函数:

CCCryptorStatus CCCrypt(
CCOperation op, /* kCCEncrypt, etc. */
CCAlgorithm alg, /* kCCAlgorithmAES128, etc. */
CCOptions options, /* kCCOptionPKCS7Padding, etc. */
const void *key,
size_t keyLength,
const void *iv, /* optional initialization vector */
const void *dataIn, /* optional per op and alg */
size_t dataInLength,
void *dataOut, /* data RETURNED here */
size_t dataOutAvailable,
size_t *dataOutMoved)
API_AVAILABLE(macos(10.4), ios(2.0));
  • CCOperationkCCEncrypt 加密,kCCDecrypt 解密
enum {
kCCEncrypt = 0,
kCCDecrypt,
};
typedef uint32_t CCOperation;
  • CCAlgorithm:加密算法、默认为AES
enum {
kCCAlgorithmAES128 = 0, /* Deprecated, name phased out due to ambiguity with key size */
kCCAlgorithmAES = 0,
kCCAlgorithmDES,
kCCAlgorithm3DES,
kCCAlgorithmCAST,
kCCAlgorithmRC4,
kCCAlgorithmRC2,
kCCAlgorithmBlowfish
};
typedef uint32_t CCAlgorithm;

  • CCOptions:加密模式
    ECBkCCOptionPKCS7Padding | kCCOptionECBMode
    CBCkCCOptionPKCS7Padding
enum {
/* options for block ciphers */
kCCOptionPKCS7Padding = 0x0001,
kCCOptionECBMode = 0x0002
/* stream ciphers currently have no options */
};
typedef uint32_t CCOptions;

  • key:密钥
  • keyLength:密钥长度
  • iviv 初始化向量,ECB 不需要。iv定长所以不需要长度(8字节)。
  • dataIn:加密/解密的数据
  • dataInLength:加密/解密的数据长度
  • dataOut:缓冲区(地址),存放密文/明文
  • dataOutAvailable:缓冲区大小
  • dataOutMoved:加密/解密结果大小

封装如下:

/**
* 解密字符串
*
* @param string 加密并base64编码后的字符串
* @param keyString 解密密钥
* @param iv 初始化向量(8个字节)
*
* @return 返回解密后的字符串
*/
- (NSString *)decryptString:(NSString *)string keyString:(NSString *)keyString iv:(NSData *)iv {

// 设置秘钥
NSData *keyData = [keyString dataUsingEncoding:NSUTF8StringEncoding];
uint8_t cKey[self.keySize];
bzero(cKey, sizeof(cKey));
[keyData getBytes:cKey length:self.keySize];

// 设置iv
uint8_t cIv[self.blockSize];
bzero(cIv, self.blockSize);
int option = 0;
if (iv) {
[iv getBytes:cIv length:self.blockSize];
option = kCCOptionPKCS7Padding;//CBC 加密!
} else {
option = kCCOptionPKCS7Padding | kCCOptionECBMode;//ECB加密!
}

// 设置输出缓冲区
NSData *data = [[NSData alloc] initWithBase64EncodedString:string options:0];
size_t bufferSize = [data length] + self.blockSize;
void *buffer = malloc(bufferSize);

// 开始解密
size_t decryptedSize = 0;

CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt,
self.algorithm,
option,
cKey,
self.keySize,
cIv,
[data bytes],
[data length],
buffer,
bufferSize,
&decryptedSize);

NSData *result = nil;
if (cryptStatus == kCCSuccess) {
result = [NSData dataWithBytesNoCopy:buffer length:decryptedSize];
} else {
free(buffer);
NSLog(@"[错误] 解密失败|状态编码: %d", cryptStatus);
}

return [[NSString alloc] initWithData:result encoding:NSUTF8StringEncoding];
}

上文提到使用CBC模式,可以创建一个随机的iv:

#import <Foundation/Foundation.h>
#import <CommonCrypto/CommonCryptor.h>

NSData *generateRandomIV(size_t length) {
NSMutableData *randomIV = [NSMutableData dataWithLength:length];
int result = SecRandomCopyBytes(kSecRandomDefault, length, randomIV.mutableBytes);

if (result == errSecSuccess) {
return randomIV;
} else {
// 处理生成随机IV失败的情况
return nil;
}
}

int main(int argc, const char * argv[]) {
@autoreleasepool {
// 设置AES加密参数
NSData *key = [@"YourAESKey123456" dataUsingEncoding:NSUTF8StringEncoding];
size_t ivLength = kCCBlockSizeAES128; // IV长度为16字节(AES-128)

// 生成随机IV
NSData *randomIV = generateRandomIV(ivLength);

if (randomIV) {
// 使用randomIV进行AES加密
// 这里你可以调用相应的加密方法,传入randomIV作为IV参数
// 例如,使用CommonCrypto库进行AES加密
// 具体实现将取决于你所使用的加密库和算法

// 示例:在这里调用AES加密函数,传入key和randomIV
// ...
} else {
NSLog(@"生成随机IV失败");
}
}
return 0;
}

作者:lefht0pz408mdl7yvptpm23ci
链接:https://juejin.cn/post/7273674732448792639
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

把数据库里的未付款订单改成已付款,会发生什么

导言 不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改? 大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,...
继续阅读 »

导言


不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改?



大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,求转发,谢谢。



理论


在介绍具体的内容之间,先介绍MD5算法,简单的来说,MD5能把任意大小、长度的数据转换成固定长度的一串字符,经常玩大型游戏的朋友应该都注意到过,各种补丁包、端游客户端之类的大型文件一般都附有一个MD5值,用于确保你下载文件的完整性。那么在这里,我们可以借鉴其思想,对订单的某些属性进行加密计算,得出来一个 MD5值一并保存在数据库当中。从数据库取出数据后第一时间进行校验,如果有异常更改,那么及时抛出异常进行人工处理。


实现


道理我都懂,但是我要如何做呢,别急,且听我一一道来。


这种需求听起来并不强绑定于某个具体的业务需求,这就要用到了我们熟悉的鼎鼎有名的AOP(面向切面编程)来实现。


首先定义四个类型的注解作为AOP的切入点。@Sign@Validate都是作用在方法层面的,分别用于对方法的入参进行加签和验证方法的返回值的签名。@SignField用于注解关键的不容篡改的字段。@ValidateField用于注解保存计算后得出的签名值。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sign {
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Validate {
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SignField {
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidField {
}

以订单的实体为例 sn,amt,status,userId就是关键字段,绝不能允许有人在落单到数据库后对这些字段偷偷篡改。

public class Order {
@SignField
private String sn;
@SignField
private String amt;
@SignField
private int status;
@SignField
private int userId;
@ValidField
private String sign;
}

下面就到了重头戏的部分,如何通过AOP来进行实现。


1. 定义切入点

@Pointcut("execution(@com.example.demo.annotations.Sign * *(..))")
public void signPointCut() {

}

@Pointcut("execution(@com.example.demo.annotations.Validate * *(..))")
public void validatePointCut() {

}

2.环绕切入点

@Around("signPointCut()")
public Object signAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
for (Object o : args) {
System.out.println(o);
sign(o);
}
Object res = pjp.proceed(args);
return res;
}

@Around("validatePointCut()")
public Object validateAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
Object res = pjp.proceed(args);
valid(res);
return res;
}

3. 签名的实现

  • 获取需要签名字段
private Map<String, String> getSignMap(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = new HashMap<>();
for (Field f : o.getClass().getDeclaredFields()) {
System.out.println(f.getName());
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(SignField.class)) {
String value = "";
f.setAccessible(true);
fieldNameToValue.put(f.getName(), f.get(o).toString());
}
}
}
return fieldNameToValue;
}
  • 计算出签名值,这里在属性名和属性值以外加入了我的昵称以防止他人猜测,同时使用了自定义的分隔符来加强密码强度。
private String getSign(Map<String, String> fieldNameToValue) {
List<String> names = new ArrayList<>(fieldNameToValue.keySet());
StringBuilder sb = new StringBuilder();
for (String name : names)
sb.append(name).append("@").append(fieldNameToValue.get(name));
System.out.println(sb.append("日暮与星辰之间").toString());
String signValue = DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
return signValue;
}

  • 找到保存签名的字段
private Field getValidateFiled(Object o) {
for (Field f : o.getClass().getDeclaredFields()) {
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(ValidField.class)) {
return f;
}
}
}
return null;
}

  • 对保存签名的字段进行赋值
public void sign(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
if (validateField == null)
return;
String signValue = getSign(fieldNameToValue);
validateField.setAccessible(true);
validateField.set(o, signValue);
}

  • 对从数据库中取出的对象进行验证
public void valid(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
validateField.setAccessible(true);
String signValue = getSign(fieldNameToValue);
if (!Objects.equals(signValue, validateField.get(o))) {
throw new RuntimeException("数据非法");
}

}

使用示例


对将要保存到数据库的对象进行签名

@Sign
public Order save( Order order){
orderList.add(order);
return order;
}

验证从数据库中取出的对象是否合理

@Validate
public Order query(@ String sn){
return orderList.stream().filter(e -> e.getSn().equals(sn)).findFirst().orElse(null);
}

好文分享 ⬇️
从Offer收割机到延毕到失业再到大厂996,二零二二我的兵荒马乱 - 掘金


另类年终总结:在煤老板开的软件公司实习是怎样一种体验? - 掘金


第一次值守双十一,居然没有任何意外发生?! - 掘金


大厂996三个月,我曾迷失了生活的意义,努力找回中 - 掘金


阿里实习三个月,我学会了面试时讲好自己的项目,欢迎提问 - 掘金


迟到的苏州微软实习历险记 - 掘金


什么时候要用到本地缓存,比Redis还要快?怎么用? - 掘金


作者:日暮与星辰之间
链接:https://juejin.cn/post/7144378128914186270
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

🤔️《你不知道的JavaScript》到底讲了些什么?

开始之前 在计算机科学的领域中,JavaScript是一门无法忽视的重要语言,深受许多开发者的喜爱。然而,它背后隐藏的复杂性和奥秘许多开发者并不为知。《你不知道的JavaScript》这三卷之作,对我个人而言真的算是常看常新,从刚从事前端开发到如今已经独当一面...
继续阅读 »

开始之前


在计算机科学的领域中,JavaScript是一门无法忽视的重要语言,深受许多开发者的喜爱。然而,它背后隐藏的复杂性和奥秘许多开发者并不为知。《你不知道的JavaScript》这三卷之作,对我个人而言真的算是常看常新,从刚从事前端开发到如今已经独当一面,从这本书中受益良多。因此在多次阅读后我选择用内容梗概+案例解析的形式将其精华部分记录下来,以供个人翻阅和与大家分享,那么我们开始吧


上卷


上卷主要针对语言核心的一些关键概念,如作用域、闭包、this等。本文将为笔者阅读过程中所总结和提炼的关键知识点与经典案例


1. 作用域是什么?


内容概览


本章介绍了JavaScript中的作用域概念,解释了变量如何被储存以及如何被引用。


实例分析

var a = 2;

function foo() {
var a = 3;
console.log(a); // 3
}

foo();

console.log(a); // 2

在这个例子中,我们看到a在全局作用域和foo函数的作用域中都有定义。函数内部的a不会影响到全局作用域中的a


2. 词法作用域


内容概览


词法作用域意味着作用域是由函数声明的位置来决定的,而不是函数调用的位置。


实例分析

function foo() {
console.log(a);
}

function bar() {
var a = 3;
foo();
}

var a = 2;

bar(); // 2

尽管foo函数在bar函数内部被调用,但foo函数的词法作用域仍然使其能够访问外部的变量a,所以输出为2。


3. 函数与块作用域


内容概览


介绍了函数作用域和块作用域,以及如何利用它们来避免变量冲突和其他问题。


实例分析

if (true) {
let a = 2;
console.log(a); // 2
}

console.log(a); // ReferenceError

使用let定义的变量具有块作用域,只能在声明它的块中访问。


4. 提升


内容概览


解释了提升(hoisting)现象,即变量和函数声明会被移动到它们所在的作用域顶部。


实例分析

foo(); // "Hello"

function foo() {
console.log("Hello");
}

尽管函数foo在调用之后被声明,但由于提升,它仍然可以正常调用。


5. 作用域闭包


内容概览


解释了闭包是如何工作的,以及它在JavaScript中的重要性。


实例分析

function makeGreeting(greeting) {
return function(name) {
console.log(greeting + ", " + name);
};
}

let sayHello = makeGreeting("Hello");
sayHello("Alice"); // "Hello, Alice"

sayHello函数是一个闭包,它记住了创建它时的作用域,因此能够访问greeting变量。


6. 词法分析和语法分析


实例分析


来看以下代码:

function add(x, y) {
return x + y;
}

let sum = add(5, 7);

在词法分析阶段,这段代码可能被分解为多个词法单元:function, add, (, x, ,, y, ), {, return, +, ;, }, let, =, 5, 7 等。然后,语法分析器会将这些词法单元组合成AST。


7. L查询与R查询


实例分析

function calculateArea(radius) {
const pi = 3.141592653589793;
return pi * radius * radius;
}

let r = 5;
let area = calculateArea(r);

在这个例子中,考虑let area = calculateArea(r);这行代码。对于calculateArea,它是RHS查询,因为我们需要获得这个函数的引用来执行它。而r也是RHS查询,因为我们正在获取它的值来传递给函数。


calculateArea函数内,pi和两次radius的查询都是RHS查询,因为我们获取它们的值来执行乘法操作。而return语句中的计算结果则赋值给了隐式的返回值,这涉及到LHS查询。


对于let r = 5;,这里的r是一个LHS查询,因为我们给它赋值了。


中卷


中卷的内容相比上卷来说更加深入且晦涩,其中包括令初学者头昏脑胀的面向对象编程与this原型链相关的知识,我将以更多的篇幅和更深入的案例来帮助大家进行理解


1. 对象


实例分析 1


使用工厂函数和构造器来创建对象:

function createPerson(name, age) {
return {
name,
age,
greet() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
}
};
}

const person1 = createPerson('Alice', 30);
person1.greet();

深入分析


这是一个工厂函数的例子,允许我们快速创建具有相似属性和方法的对象。在此,greet方法是每个对象的一部分,这可能导致内存浪费,因为每次创建新对象时,都会为greet方法分配新的内存。


实例分析 2


使用getters和setters:

const book = {
title: 'In Search of Lost Time',
author: 'Marcel Proust',
get description() {
return `${this.title} by ${this.author}`;
},
set description(value) {
[this.title, this.author] = value.split(' by ');
}
};

book.description = '1984 by George Orwell';
console.log(book.title); // Outputs: 1984

深入分析


这个案例展示了如何利用对象的getters和setters来动态地管理对象的属性。通过setter,我们能够同时更新titleauthor,而getter则为我们提供了书的描述。


2. 类


实例分析 1


多态的使用:

class Animal {
makeSound() {
console.log('Some generic sound');
}
}

class Dog extends Animal {
makeSound() {
console.log('Woof');
}
}

const animal1 = new Animal();
const animal2 = new Dog();

animal1.makeSound(); // Outputs: Some generic sound
animal2.makeSound(); // Outputs: Woof

深入分析


多态是面向对象编程中的一个关键概念,允许我们创建能够以多种形式表现的对象。在此,我们看到Dog类重写了Animal类的makeSound方法,实现了多态。


实例分析 2


静态方法的使用:

class MathUtility {
static add(x, y) {
return x + y;
}
}

console.log(MathUtility.add(5, 3)); // Outputs: 8

深入分析


这个案例展示了如何在类中使用静态方法。与实例方法不同,静态方法不需要创建类的实例就可以被调用。它们通常用于执行与类的实例无关的操作。


3. 原型


实例分析


一个动态添加到原型的方法:

function Cat(name) {
this.name = name;
}

Cat.prototype.purr = function() {
console.log(`${this.name} is purring.`);
};

const whiskers = new Cat('Whiskers');
whiskers.purr(); // Outputs: Whiskers is purring.

深入分析


在此例中,我们后期将purr方法添加到Cat的原型中。这意味着即使在添加此方法后创建的所有Cat实例都可以访问它。这展示了原型继承的动态性质:我们可以在任何时候修改原型,这些更改会反映在所有继承了那个原型的对象上。


4. this和对象原型


JavaScript中的this是一个非常深入且经常被误解的主题。this并不是由开发者选择的,它是由函数调用时的条件决定的。


实例分析


考虑以下场景:

function showDetails() {
console.log(this.name);
}

const obj1 = {
name: 'Object 1',
display: showDetails
};

const obj2 = {
name: 'Object 2',
display: showDetails
};

obj1.display(); // Outputs: Object 1
obj2.display(); // Outputs: Object 2

深入分析


在这里,showDetails函数查看this.name。当它作为obj1的方法被调用时,this指向obj1。当它作为obj2的方法被调用时,this指向obj2。这说明了this的动态性质:它是基于函数如何被调用的。


5. 原型链


当试图访问一个对象的属性或方法时,JavaScript会首先在该对象本身上查找。如果未找到,它会在对象的原型上查找,然后是原型的原型,以此类推,直到找到该属性或到达原型链的末尾。


实例分析

function Animal(sound) {
this.sound = sound;
}

Animal.prototype.makeSound = function() {
console.log(this.sound);
}

function Dog() {
Animal.call(this, 'Woof');
}

Dog.prototype = Object.create(Animal.prototype);

const dog = new Dog();
dog.makeSound(); // Outputs: Woof

深入分析


当我们调用dog.makeSound()时,JavaScript首先在dog对象上查找makeSound。未找到后,它会在Dog的原型上查找。还是未找到,然后继续在Animal的原型上查找,最后找到并执行它。


6. 行为委托


行为委托是原型的一种使用模式,涉及到对象之间的关系,而不仅仅是克隆或复制。


实例分析

const Task = {
setID: function(ID) { this.id = ID; },
outputID: function() { console.log(this.id); }
};

const XYZ = Object.create(Task);

XYZ.prepareTask = function(ID, Label) {
this.setID(ID);
this.label = Label;
};

XYZ.outputTaskDetails = function() {
this.outputID();
console.log(this.label);
};

const task = Object.create(XYZ);
task.prepareTask(1, 'create demo for delegation');
task.outputTaskDetails(); // Outputs: 1, create demo for delegation

深入分析


XYZ不是Task的复制,它链接到Task。当我们在XYZ对象上调用setIDoutputID方法时,这些方法实际上是在Task对象上运行的,但this指向的是XYZ。这就是所谓的委托:XYZ在行为上委托给了Task


下卷


下卷的内容相较于中卷就基础了很多,更偏向于实际应用方向


1. 类型和语法


实例分析 - 类型转换


考虑以下的隐式类型转换:

var a = "42";
var b = a * 1;
console.log(typeof a); // "string"
console.log(typeof b); // "number"

深入分析


在这里,变量a是一个字符串,但当我们尝试与数字进行乘法操作时,它会被隐式地转换为一个数字。这是因为乘法操作符期望它的操作数是数字,因此JavaScript会尝试将字符串a转换为一个数字。


2. 异步和性能


实例分析 - Promises

function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data fetched!");
}, 2000);
});
}

fetchData().then(data => {
console.log(data); // Outputs: "Data fetched!" after 2 seconds
});

深入分析


Promises 提供了一种更简洁、更具可读性的方式来处理异步操作。在上面的例子中,fetchData函数返回一个Promise。setTimeout模拟了异步数据获取,数据在2秒后可用。当数据准备好后,resolve函数被调用,then方法随后执行,输出数据。


3. ES6及其以上的特性


实例分析 - 使用箭头函数

const numbers = [1, 2, 3, 4];
const doubled = numbers.map(num => num * 2);
console.log(doubled); // [2, 4, 6, 8]

深入分析


箭头函数提供了一种更简洁的方式来定义函数,尤其是对于那些简短的、无状态的函数来说。在上述例子中,我们使用箭头函数简洁地定义了一个函数,该函数将其输入值乘以2,并使用map方法将其应用到一个数字数组中。


实例分析 - 使用async/await

async function fetchDataAsync() {
let response = await fetch('https://api.example.com/data');
let data = await response.json();
return data;
}

fetchDataAsync().then(data => console.log(data));

深入分析


async/await是ES7引入的特性,允许以同步的方式编写异步代码。在这个案例中,fetchDataAsync函数是一个异步函数,这意味着它返回一个Promise。await关键字使我们能够等待Promise解析,然后继续执行后面的代码。这消除了回调地狱,使异步代码更容易阅读和维护。


4. 迭代器和生成器


实例分析 - 使用生成器函数

function* numbersGenerator() {
yield 1;
yield 2;
yield 3;
}

const numbers = numbersGenerator();

console.log(numbers.next().value); // 1
console.log(numbers.next().value); // 2
console.log(numbers.next().value); // 3

深入分析


生成器函数使用function*声明,并且可以包含一个或多个yield表达式。每次调用生成器对象的next()方法时,函数都会执行到下一个yield表达式,并返回其值。这使我们能够按需产生值,非常适用于大数据集或无限数据流。


5. 增强的对象字面量


实例分析

const name = "Book";
const price = 20;

const book = {
name,
price,
describe() {
return `${this.name} costs ${this.price} dollars.`;
}
};

console.log(book.describe()); // "Book costs 20 dollars."

深入分析


增强的对象字面量允许我们在声明对象时使用更简洁的语法。在这里,我们直接使用变量名作为键,并使用简短的方法定义形式。这使得对象声明更为简洁和可读。


6. 解构赋值


实例分析

const user = {
firstName: "Alice",
lastName: "Smith"
};

const { firstName, lastName } = user;

console.log(firstName); // Alice
console.log(lastName); // Smith

深入分析


解构赋值允许我们从数组或对象中提取数据,并赋值给新的或已存在的变量。在此例中,我们从user对象中提取了firstNamelastName属性,并将它们赋值给了同名的新变量。


7. 模块


实例分析 - ES6模块导入和导出

// math.js
export function add(x, y) {
return x + y;
}

export function subtract(x, y) {
return x - y;
}

// app.js
import { add, subtract } from './math.js';

console.log(add(5, 3)); // 8
console.log(subtract(5, 3)); // 2

结语


经过对《你不知道的JavaScript》上、中、下三卷的深入探索,我们更加清晰地理解了JavaScript这门语言的复杂性、深度和强大之处。这不仅仅是关于语法或是新特性,更是关于理解其背后的哲学和设计思想。作为开发者,真正的掌握并不只是会用,而是要知其所以然。此书为我们打开了一扇探索JavaScript的大门,但真正的旅程,才刚刚开始。我们的每一步前行,都是为了更好地理解、更精准地应用,为编写出更高效、更优雅的代码而努力。


作者:J3LLYPUNK
链接:https://juejin.cn/post/7274839607656890429
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

话说工作的“边界感”

一句话的合作例子 今天有一个业务的运营同学匆匆忙忙找到我,说要跟我这边有一个问题,业务要合作,然后已经提前和我老板打过招呼了。事情是这样的,我这边负责的是工作台门户,然后作为一个平台业务,有大量的客户需要找到对应的服务商来给自己定制门户。然后这位同学负责的是定...
继续阅读 »


一句话的合作例子


今天有一个业务的运营同学匆匆忙忙找到我,说要跟我这边有一个问题,业务要合作,然后已经提前和我老板打过招呼了。事情是这样的,我这边负责的是工作台门户,然后作为一个平台业务,有大量的客户需要找到对应的服务商来给自己定制门户。然后这位同学负责的是定制业务,所以要统一把所有的定制业务全部收口,但是这位定制同学的业务没有对应的技术研发同学,所以他就找到我的老板同步了这个情况。


分工协作的本质


其实问题的合作方式是比较简单的,但是当她跟我说最终客户定制界面也由我来开发的时候,比如定制的费用是多少、定制的时间要求等等,我就觉得问题有些奇怪了。因为按照常理来说,我负责的是工作台,但是由于有定制业务相关的逻辑,所以我要处理一定的业务逻辑,但是让我去承担这个定制页面的开发,我觉得是有问题的。


举一个简单的例子,假如我现在是一个博物馆,原来用户是直接可以免费没有任何阻挡地进入博物馆的,但是突然有一天市政府说所有公共设施要收费了,那么对于博物馆的工作人员来说肯定是支持的,但是突然你又告诉我,我这个博物馆还要去维护全市统一的收费系统,这个就是不合理的。哪怕他找我的主管沟通结果也是一样,因为我和我的主管是属于博物馆体系的工作人员,他也没有义务和责任去维护整个所有的公共设施的收费系统。但是作为公共设施系统的一部分,如果有统一的收费规则,那么对于博物馆来说也是要遵守的。


所以这面就引出了我对于业务边界上面的一个思考。我经常看到同学给我转发一段话,说跟你老板打沟通了业务的合作情况,你的老板觉得非常不错,于是这位同学就匆匆忙忙的找到我来开始谈业务,谈实施细节并且需要我快速落地。而实际上这种所谓的业务协同的情况大部分也只会停留在沟通的层面,在最终落地的时候,往往和业务同学的预期不相符。在业务同学眼里看来,就是你们阴奉阳违,恨不得马上就开始投诉。


这里面非常核心的一个误区就是业务同学往往没有划清业务界限和系统界限的边界。对于业务同学来说,边界可能不会那么明显,但对于一个系统开发的同学来说,业务和边界是非常明显的,因为系统是物理存在的,有着天然的“隔离”。所以对于业务同学,如果想要顺畅的推动业务,必须要事先清晰的划分参与方的角色和业务边界,并且可以进一步了解到系统边界在哪里。


这个由谁来做就涉及到了一个很大权责问题。简单来说就是我做了有什么好处,换句话来说做这件事和我的职务目标有什么关系?如果没有关系,我为什么要做?就算同一个公司,也有很多需要完成的事,比如公司保洁不到位,我作为公司的员工,是否也立即从事保洁?


如果是我的职务目标,我的责任有多少?我承担了既定的责任,那我是否能够承担起对应的权利?在我上次借用的博物馆的例子可以看到,如果我承担了全市的公共系统的收费设施的维护,那么我的权利在哪里?如果我的权利只是在博物馆这一个地方的收费上面,那么这就变成了权责不对等。


但是如果我做成了全市公共收费系统,并且能掌管全市所有公共设施的收费业务,那么对于这个收费系统的开发权则是相等的,但是对于我本身职务的权责又是不等的,因为公司请我来管理博物馆的,而非管理整个全市的收费系统。


所以在思考业务推进的时候,首先就要思考系统的边界和权责对等关系,如果这一层面没有理清楚的话,合作大概率是不能完成的。而很多的业务同学就以“我和你老板谈好的东西,为什么你不去做”这么简单的方式来拷问协同关系,我觉得是非常的幼稚的。


所以我希望其实我们在去和别人沟通业务的时候,往往要带着权责,带着边界的思考,去和对方去讨论,去协商,去沟通。简单来说,我在跟你聊之前,我要知道你的系统,你的业务边界在哪里?我跟你聊的时候,我要清晰地告诉你,这个事情做了对你有什么好处,对我有什么好处,哪部分应该你做,哪部分应该我来做。只有在这样的一种沟通方式下面才是真正合理的,真正是可以落地的沟通和协作方式。


而在这些问题没有达成一致之前,由谁来做都没有定下来的时候,应该先去往上升,在顶层设计里面去规划去重新思考如何从组织设计的方式去让业务协作自然的发生。


总结


这里再总结一下,这里是一个小的心得。这个案例也告诉我们,我们去沟通协同的时候要有边界感,包括业务的边界和系统的边界。只有把边界理顺了,合作才有可能。


作者:ali老蒋
链接:https://juejin.cn/post/7233808084085309477
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

React的并发悖论

大家好,我卡颂。 当一个React应用逻辑变得复杂后,组件render花费的时间会显著增长。如果从组件render到视图渲染期间消耗的时间过长,用户就会感知到页面卡顿。 为了解决这个问题,有两个方法:让组件render的过程从同步变为异步,这样render过程...
继续阅读 »

大家好,我卡颂。


当一个React应用逻辑变得复杂后,组件render花费的时间会显著增长。如果从组件render视图渲染期间消耗的时间过长,用户就会感知到页面卡顿。


为了解决这个问题,有两个方法:

  1. 组件render的过程从同步变为异步,这样render过程页面不会卡死。这就是并发更新的原理

  2. 减少需要render的组件数量,这就是常说的React性能优化


通常,对于不同类型组件,我们会采取以上不同的方法。比如,对于下面这样的有耗时逻辑的输入框,方法1更合适(因为并发更新能减少输入时的卡顿):

function ExpensiveInput({onChange, value}) {
// 耗时的操作
const cur = performance.now();
while (performance.now() - cur < 20) {}

return <input onChange={onChange} value={value}/>;
}

那么,能不能在整个应用层面同时兼顾这2种方式呢?答案是 —— 不太行。


这是因为,对于复杂应用,并发更新与性能优化通常是相悖的。就是本文要聊的 —— 并发悖论。


欢迎加入人类高质量前端交流群,带飞


从性能优化聊起


对于一个组件,如果希望他非必要时不render,需要达到的基本条件是:props的引用不变。


比如,下面代码中Child组件依赖fn props,由于fn是内联形式,所以每次App组件render时引用都会变,不利于Child性能优化:

function App() {
return <Child fn={() => {/* xxx */}}/>
}

为了Child性能优化,可以将fn抽离出来:

const fn = () => {/* xxx */}

function App() {
return <Child fn={fn}/>
}

fn依赖某些props或者state时,我们需要使用useCallback

function App({a}) {
const fn = useCallback(() => a + 1, [a]);
return <Child fn={fn}/>
}

类似的,其他类型变量需要用到useMemo


也就是说,当涉及到性能优化时,React的代码逻辑会变得复杂(需要考虑引用变化问题)。


当应用进一步复杂,会面临更多问题,比如:

  • 复杂的useEffect逻辑

  • 状态如何共享


这些问题会与性能优化问题互相叠加,最终导致应用不仅逻辑复杂,性能也欠佳。


性能优化的解决之道


好在,这些问题有个共同的解决方法 —— 状态管理。


上文我们聊到,对于性能优化,关键的问题是 —— 保持props引用不变。


在原生React中,如果a依赖bb依赖c。那么,当a变化后,我们需要通过各种方法(比如useCallbackuseMemo)保持bc引用的稳定。


做这件事情本身(保持引用不变)对开发者来说就是额外的心智负担。那么,状态管理是如何解决这个问题的呢?


答案是:状态管理库自己管理所有原始状态以及派生状态。


比如:

  • Recoil中,基础状态类型被称为Atom,其他派生状态都是基于Atom组合而来

  • Zustand中,基础状态都是create方法创建的实例

  • Redux中,维护了一个全局状态,对于需要用到的状态通过selector从中摘出来


这些状态管理方案都会自己维护所有的基础状态与派生状态。当开发者从状态管理库中引入状态时,就能最大限度保持props引用不变。


比如,下例用Zustand改造上面的代码。由于状态a和依赖afn都是由Zustand管理,所以fn的引用始终不变:

const useStore = create(set => ({
a: 0,
fn: () => set(state => ({ a: state.a + 1 })),
}))


function App() {
const fn = useStore(state => state.fn)
return <Child fn={fn}/>
}

并发更新的问题


现在我们知道,性能优化的通用解决途径是 —— 通过状态管理库,维护一套逻辑自洽的外部状态(这里的外部是区别于React自身的状态),保持引用不变。


但是,这套外部状态最终一定会转化为React的内部状态(再通过内部状态的变化驱动视图更新),所以就存在状态同步时机的问题。即:什么时候将外部状态与内部状态同步?


在并发更新之前的React中,这并不是个问题。因为更新是同步、不会被打断的。所以对于同一个外部状态,在整个更新过程中都能保持不变。


比如,在如下代码中,由于List组件的render过程不会打断,所以list在遍历过程中是稳定的:

function List() {
const list = useStore(state => state.list)
return (
<ul>
{list.map(item => <Item key={item.id} data={item}/>}
</ul>
)
}

但是,对于开启并发更新的React,更新流程可能中断,不同的Item组件可能是在中断前后不同的宏任务中render,传递给他们的data props可能并不相同。这就导致同一次更新,同一个状态(例子中的list)前后不一致的情况。


这种情况被称为tearing(视图撕裂)。


可以发现,造成tearing的原因是 —— 外部状态(状态管理库维护的状态)与React内部状态的同步时机出问题。


这个问题在当前React中是很难解决的。退而求其次,为了让这些状态库能够正常使用,React专门出了个hook —— useSyncExternalStore。用于将状态管理库触发的更新都以同步的方式执行,这样就不会有同步时机的问题。


既然是以同步的方式执行,那肯定没法并发更新啦~~~


总结


实际上,凡是涉及到自己维护了一个外部状态的库(比如动画库),都涉及到状态同步的问题,很有可能无法兼容并发更新。


所以,你会更倾向下面哪种选择呢:

  1. care并发更新,以前React怎么用,现在就怎么用

  2. 根据项目情况,平衡并发更新与性能优化的诉求


作者:魔术师卡颂
链接:https://juejin.cn/post/7223787990950232125
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

聊一聊过度设计!

  新手程序员在做设计时,因为缺乏经验,很容易写出欠设计的代码,但有一些经验的程序员,尤其是在刚学习过设计模式之后,很容易写出过度设计的代码,而这种代码比新手程序员的代码更可怕,过度设计的代码不仅写出来时的成本很高,后续维护的成本也高。因为相对于毫无设计的代码...
继续阅读 »

  新手程序员在做设计时,因为缺乏经验,很容易写出欠设计的代码,但有一些经验的程序员,尤其是在刚学习过设计模式之后,很容易写出过度设计的代码,而这种代码比新手程序员的代码更可怕,过度设计的代码不仅写出来时的成本很高,后续维护的成本也高。因为相对于毫无设计的代码,过度设计的代码有比较高的理解成本。说这么多,到底什么是过度设计?


什么是过度设计?


  为了解释清楚,我这里用个类比,假如你想拧一颗螺丝,正常的解决方案是找一把螺丝刀,这很合理对吧。 但是有些人就想:“我就要一个不止能拧螺丝的工具,我想要一个可以干各种事的工具!”,于是就花大价钱搞了把瑞士军刀。在你解决“拧螺丝”问题的时候,重心早已从解决问题转变为搞一个工具,这就是过度设计。

   再举个更技术的例子,假设你出去面试,面试官让你写一个程序,可以实现两个数的加减乘除,方法出入参都给你提供好了 int calc(int x, int y, char op),普通程序员可能会写出以下实现。

    public int calc(int x, int y, int op) {
if (op == '+') {
return x + y;
} else if (op == '-') {
return x - y;
} else if (op == '*') {
return x * y;
} else {
return x / y;
}
}

  而高级程序员会运用设计模式,写出这样的代码:

public interface Strategy {
int calc(int x, int y);
}

public class AddStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x + y;
}
}

public class MinusStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x - y;
}
}
/**
* 其他实现
*/
public class Main {
public int calc(int x, int y, int op) {
Strategy add = new AddStrategy();
Strategy minux = new MinusStrategy();
Strategy multi = new MultiStrategy();
Strategy div = new DivStrategy();
if (op == '+') {
return add.calc(x, y);
} else if (op == '-') {
return minux.calc(x, y);
} else if (op == '*') {
return multi.calc(x, y);
} else {
return div.calc(x, y);
}
}
}

  策略模式好处在于将计算(calc)和具体的实现(strategy)拆分,后续如果修改具体实现,也不需要改动计算的逻辑,而且之后也可以加各种新的计算,比如求模、次幂……,扩展性明显增强,很是牛x。 但光从代码量来看,复杂度也明显增加。回到我们原始的需求上来看,如果我们只是需要实现两个整数的加减乘除,这明显过度设计了。


过度设计的坏处


  个人总结过度设计有两大坏处,首先就是前期的设计和开发的成本问题。过度设计的方案,首先设计的过程就需要投入额外的时间成本,其次越复杂的方案实现成本也就越高、耗时越长,如果是在快速迭代的业务中,这些可能都会决定到业务的生死。其次即便是代码正常上线后,其复杂度也会导致后期的维护成本高,比如当你想将这些代码交接给别人时,别人也需要付出额外的学习成本。


  如果成本问题你都可以接受,接下来这个问题可能影响更大,那就是过度设计可能会影响到代码的灵活性,这点听起来和做设计的目的有些矛盾,做设计不就是为了提升代码的灵活性和扩展性吗!实际上很多过度设计的方案搞错了扩展点,导致该灵活的地方不灵活,不该灵活的地方瞎灵活。在机器学习领域,有个术语叫做“过拟合”,指的是算法模型在测试数据上表现完美,但在更广泛的数据上表现非常差,模式缺少通用性。 过度设计也会出现类似的现象,就是缺少通用性,在面对稍有差异的需求上时可能就需要伤筋动骨级别的改造了。


如何避免过度设计


  既然过度设计有着成本高和欠灵活的问题,那如何避免过度设计呢!我这里总结了几个方法,希望可以帮到大家。


充分理解问题本身


  在设计的过程中,要确保充分理解了真正的问题是什么,明确真正的需求是什么,这样才可以避免做出错误的设计。


保持简单


  过度设计毫无例外都是复杂的设计,很多时候未来有诸多的不确定性,如果过早的针对某个不确定的问题做出方案,很可能就白做了,等遇到真正问题的时候再去解决问题就行。


小步快跑


  不要一开始就想着做出完美的方案,很多时候优秀的方案不是设计出来的,而是逐渐演变出来的,一点点优化已有的设计方案比一开始就设计出一个完美的方案容易得多。


征求其他人的意见


  如果你不确定自己的方案是不是过度设计了,可以咨询下其他人的,尤其是比较资深的人,交叉验证可以快速让你确认问题。


总结


  其实在业务的快速迭代之下,很难判定当前的设计是欠设计还是过度设计,你当前设计了一个简单的方案,未来可能无法适应更复杂的业务需求,但如果你当前设计了一个复杂的方案,有可能会浪费时间……。 在面对类似这种不确定性的时候,我个人还是比较推崇大道至简的哲学,当前用最简单的方案,等需要复杂性扩展的时候再去重构代码。


作者:xindoo
链接:https://juejin.cn/post/7204423284905738298
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

👣 我在语雀做图册 - 更整齐的瀑布流算法

web
🙋🏻‍♀️ 编者按:本文作者是蚂蚁集团前端工程师亦池。介绍了语雀图册功能背后的更整齐的瀑布流算法,还有一段和 chatGPT 纠缠的故事 🤪,一起来看看~ 🏞️ 介绍一下图册 先来看看我们语雀年前上线的图册功能: 欢迎大家使用图册更好的展示自己的图片,...
继续阅读 »

🙋🏻‍♀️ 编者按:本文作者是蚂蚁集团前端工程师亦池。介绍了语雀图册功能背后的更整齐的瀑布流算法,还有一段和 chatGPT 纠缠的故事 🤪,一起来看看~



🏞️ 介绍一下图册


先来看看我们语雀年前上线的图册功能:
image.png


image.png



欢迎大家使用图册更好的展示自己的图片,秀秀最近的摄影作品、po一下最近的好吃的好玩的、晒几张靓照~
目前图册只是上了一个基础的初版,还有很多地方在体验和产品设计上也会继续优化,包括针对单张图的删除、排序,图册的尺寸调整,更快捷的把各种来源的图片放进图册里,大家有一些想法也欢迎提建议~



开发故事


🧐 瀑布流能不能再整齐一些


瀑布流是一个不新鲜的布局方式了,看到这个我第一反应自然是使用社区的开源库按需裁剪一下用起来。刚发布时也是这么上线的。扒过代码参考的开源库有:



但第一版本其实回发生下图左侧尴尬的情况:
image.png
肉眼可见我们要的是上图右侧的效果。


常见瀑布流算法的问题



原因:社区主流的瀑布流计算思路都是将已知高度的图片(实现上可以是图片加载完成后获取高度触发重新布局)分发了列容器里,每列记录实时高度,对于每一张新来的图片分发规则是放入最短的那一列。专业点说是贪心算法的思想。



所以当最后一张是长图时就会对布局的齐平性导致很大的冲击。(当然这不是说社区的方案都low,开源产品可能更多考虑了普适情况,譬如可能无法提前知道所有图片尺寸信息,全部加载完再重新布局一次又给用户带来干扰,甚至是懒加载场景更不好优雅的展示处理。)


在语雀编辑器场景,我们对于要布局的那批图片是能拿到宽高信息的,完全可以对所有图片整体考虑,计算一个最优结果再渲染,可以做到不被最后一张长图影响整体。


一开始我觉得这是个单纯的算法问题,可以抽象成将一个数字数组拆分成n个数组,使每个数组的数字和尽量接近,我觉得应该是有一种经典算法来解决这类问题的,譬如动态规划、背包问题之类的。


这么经典的问题不如问chatGPT吧,此处插入一段和chatGPT纠缠的故事。结论是它没给我找到靠谱答案。感兴趣的可以展开后面章节的折叠块看看这个让人哭笑不得的过程🙄。


💁‍♀️ 分析一下


chatGPT没能给我正确答案,我又是个基础算法的渣渣,想先找个方向再进去研究怎么实现,于是请教了一下一个酷爱刷算法题的师妹,得到的方向是:“这是个负载均衡类型问题,具有NP hard复杂度,无法获得最佳解,只能求近似最优解,用动态规划的思想是没错的”。


啥是NP hard复杂度,可以看后面的【基础知识】章节的科普。我也不清楚怎么证明这真的是一个NP hard复杂度的问题,但基础知识告诉我这类复杂度的问题往往复杂度是阶乘级别的,就是不是我们常见的O(n)、O(logn)、O(n^2)这种经典算法的复杂度,他们的复杂度叫做有多项式解。阶乘级别意味着暴力穷举,这往往是计算机无法接受的时间,也毫无算法可言。


咱这个问题,求解最优解时,每一张图片的摆放都影响着后面图片的位置,每张图之间都有关联,想想似乎确实只有穷举才能真正的找到最优解。加上对师妹算法水平的信任,我开始把问题缩减到动态规划领域。


那就拆解子问题,先计算子问题的近似最优解。


🏄‍ ♀️解决方案


核心思想:




  1. 计算平均值,让每一组的和尽量接近均值,最终每组和的差异才会最小

  2. 将原数组arr从大到小排序,降低分组时便利查找的复杂度

  3. 遍历原数组arr,从目标的n个分组的第一组开始装数据,直到它接近均值停止。这里注意接近的意思不是<=avg,而是在均值临界点,加上一个值num1 < avg后,和均值的差值是delta,往前遍历找(意味着num2 > num1)第一个没被分组的数据num2放入当前组后,num2 - avg < delta,如果是的则装num2,否则装num1。确保装的是最接近均值的数。

  4. 对于最后一个分组n-1要装数据时,需要确保arr的每一个数据都被分配完,并且各组结果最小,所以最后一组的策略不参考平均值,而是按和最小的分组去塞arr里的每一个数据。



另外注意,对于已经分好组的数据打个标,以免被重复分组。


这里我们是在拆解子问题



  • 把复杂的分组后每组方差最小的问题,转化为让每组和最接近平均值的问题,将整体的问题拆解成了n个组的问题

  • n个组塞值时,又是一个找数据使它最接近均值的子问题


其中为了降低复杂度不搞遍历的最优,确实只做到了近似最优解。譬如放值前先做了排序,只要当前数据放进去 < avg都先无脑放,就会出现,譬如剩下的数据有[48, 25, 25], 均值是50,本来我们可以放[25,25]得到最接近均值的数据,但现在只放入了48。


🤪 图片场景的特殊考虑因子


当我把一个纯数学解放入瀑布流场景时,发现事情并没有这么简单,算法的最优还是要为图片展示场景效果的最优做一些让步


参差感


譬如你看这个是最优解么?
image.png
因为我们先做了排序,并且按排序的数据顺序做分配,所以长图它它它它都跑到同一列去了。image.png
这个视觉上没有了参差美可受不了。


于是在接近最优的做法上妥协一步。先把排序前n的数据挨个放到n组,让个高的先均匀分布。


结合保留用户本来的顺序,是不是舒服一些:
image.png


这里依旧不是最佳效果,因为只取了前n个,试想我们如果是3组,5个长图,还是有一组全是长图。但长与短的边界实在无法敲定,除非再搞个每张图片高度的均值,大于均值一定阈值的数据先均匀分布到n组,但这种操作的数据越多,越影响到底部整体的平齐效果。所以还是只选了和组数相同的前n张这么处理。我估摸着大多数用户在文档里的图片是个辅助,不会搞出特别大数量级还夹杂很多长短分明的图。当前能保持一定数量级 (<10)展示上不会有太大问题。


排序


尽量得保证用户原图的顺序,所以需要记录原图的顺序,然后在分组完成后:




  1. 每列里按原图顺序重排下顺序

  2. 列与列之间按第一个图的顺序重排下顺序



能做到尽量接近原顺序但不绝对。


纯数字上[[25], [25], [25,25]][[25,25], [25], [25]]的分组没有差别。但是图片场景又不一样了:
image.png
这排列总透着一股奇怪image.png
于是再让步牺牲一下复杂度:



装最后一组数据分配余数之前,先把分配好的分组,先排序,组与组的和相等时优先放入排前面的数组。



当前版本优缺点


目前至少是在最平齐和图片参差感之间谋求的一个较优解,但绝不是最优解,理论上此类问题不穷举遍历获得不了最优解。但我们可以通过优化局部策略,使它更靠近最优解。不过一定是优于贪心算法把每张图放入高度最小列的做法。这里如果有深入研究过瀑布流的小伙伴有更优的方案,欢迎提供,让语雀的瀑布流更整齐~


做事情咱也不能只说好的,对问题缄口不言,目前的问题有:



  • 前面也说过,如果大量图片,并且存在 分组张数n 的与其他图片长度拉开巨大差距的图片,排版还是不够有参差感

  • 先按大小排序,后分组,会对原图顺序造成偏差,很难复原严格的行列顺序,但用户还是能一定程度的干预排序,只是无法满足一定要求图A和图B不放入同一列这种诉求。从这个角度说,顺序上不如贪心算法方案更接近原顺序,贪心方案的最后一张长图问题其实可以通过主动拖拽顺序把长图放到前面来解决掉,但是这对用户的理解力要求太高了。


anyway,以下的数据哪个算法也无法救🥲。目前列数是根据展示区宽度弹性计算的,这种想优雅可能要触发列数的改变规则了。
image.png


chatGPT的插曲


点我展开查看哭笑不得的过程### 第1轮
image.png
一开始它给了我个贪心算法的不是最优解,得让它进阶


第2轮


image.pngimage.png
看上去很高深,但这测试数据结果不对啊。
我换个说法?是不是不能理解什么叫数字加和尽量接近


第3轮


image.png
结果不对,继续让他换个解法


第4轮


image.png
还是肉眼可见的不对,虽然我肉眼分的也不是最优解,最后我的算法告诉我是可以分成三组和都是80的:[80], [32, 32, 12, 3, 1], [30, 21, 20, 9]


那么问题在哪呢,我尝试问了它一个很简单的问题:
image.png
原来加和都求不对,我放弃它了。。。


:::warning
综上:chatGPT能高效省事让你偷懒,但前提是你得能区分出它的答案靠不靠谱,如果你都不知道真相的问题仍给他,就会被忽悠了也不知道。另外别想用它帮你写笔试题了,它只根据语义生成,但并不真的运行代码,给的代码和结果可能完全不匹配。
:::


📔 基础知识


资料:




复杂度被分为两种级别:一种是O(1),O(log(n)),O(n^a)等,我们把它叫做多项式级的复杂度,因为它的规模n出现在底数的位置;另一种是O(a^n)和O(n!)型复杂度,它是非多项式级的,其复杂度计算机往往不能承受




P问题: 如果一个问题可以找到一个能在多项式的时间里解决它的算法,那么这个问题就属于P问题
NP问题: NP问题不是非P类问题。NP问题是指可以在多项式的时间里验证一个解的问题。NP问题的另一个定义是,可以在多项式的时间里猜出一个解的问题
NPC问题:同时满足下面两个条件的问题就是NPC问题。首先,它得是一个NP问题;然后,所有的NP问题都可以约化到它。NPC问题目前没有多项式的有效算法,只能用指数级甚至阶乘级复杂度的搜索。
**NP-Hard问题:**它满足NPC问题定义的第二条但不一定要满足第一条。NP-Hard问题同样难以找到多项式的算法,但它不列入我们的研究范围,因为它不一定是NP问题。即使NPC问题发现了多项式级的算法,NP-Hard问题有可能仍然无法得到多项式级的算法




约化:(Reducibility,有的资料上叫“归约”)。简单地说,一个问题A可以约化为问题B的含义即是,可以用问题B的解法解决问题A,或者说,问题A可以“变成”问题B。通过对某些问题的不断约化,我们能够不断寻找复杂度更高,但应用范围更广的算法来代替复杂度虽然低,但只能用于很小的一类问题的算法。



next:拼图


接下来我们还会上线更灵活的拼图能力。**拼图算法可以实现任何尺寸的图片,保持原比例不裁剪,用户任意摆放位置,最终绘制成整齐的矩形,**这个算法实现也远比瀑布流复杂。


譬如你可以这样:
image.png
也可以拖成这样:
image.png


还可以拖成这样:
image.png


甚至拖成这样:
image.png


等等等等...... 随意组合排序,最终都能整齐。


等上线后我再写写拼图的故事~


作者:支付宝体验科技
来源:juejin.cn/post/7198370695079903291
收起阅读 »

产品经理:实现一个微信输入框

web
近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。 初期认为这应该改动不大,就是把input换...
继续阅读 »


近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。


初期认为这应该改动不大,就是把input换成textarea吧。但是实际开发过程发现并没有这么简单,本文仅作为开发过程的记录,因为是基于uniapp开发,相关实现代码都是基于uniapp


简单分析我们大概需要实现以下几个功能点:



  • 默认单行输入

  • 可多行输入,但有最大行数限制

  • 超过限制行术后内容在内部滚动

  • 支持回车发送内容

  • 支持常见组合键在输入框内换行输入

  • 多行输入时高度自适应 & 页面整体自适应


单行输入


默认单行输入比较简单直接使用input输入框即可,使用textarea的时候目前的实现方式是通过设置行内样式的高度控制,如我们的行内高度是36px,那么就设置其高度为36px。为什么要通过这种方式设置呢?因为要考虑后续多行输入超出最大行数的限制,需要通过高度来控制textarea的最大高度。


<textarea style="{ height: 36px }" />


多行输入


多行输入核心要注意的就是控制元素的高度,因为不能随着用户的输入一直增加高度,我们需要设置一个最大的行数限制,超出限制后就不再增加高度,内容可以继续输入,可以在输入框内上下滚动查看内容。


这里需要借助于uniapp内置在textarea@linechange事件,输入框行数变化时调用并回传高度和行数。如果不使用uniapp则需要对输入文字的长度及当前行高计算出对应的行数,这种情况还需要考虑单行文本没有满一行且换行的情况。


代码如下,在linechange事件中获取到最新的高度设置为textarea的高度,当超出最大的行数限制后则不处理。


linechange(event) {
const { height, lineCount } = event.detail
if (lineCount < maxLine) {
this.textareaHeight = height
}
}

这是正常的输入,还有一种情况是用户直接粘贴内容输入的场景,这种时候不会触发@linechange事件,需要手动处理,根据粘贴文本后的textarea的滚动高度进行计算出对应的行数,如超出限制行数则设置为最大高度,否则就设置为实际的行数所对应的高度。代码如下:


const paddingTop = parseInt(getComputedStyle(textarea).paddingTop);
const paddingBottom = parseInt(getComputedStyle(textarea).paddingBottom);
const textHeight = textarea.scrollHeight - paddingTop - paddingBottom;
const numberOfLines = Math.floor(textHeight / lineHeight);

if (numberOfLines > 1 && this.lineCount === 1) {
const lineCount = numberOfLines < maxLine ? numberOfLines : maxLine
this.textareaHeight = lineCount * lineHeight
}

键盘发送内容


正常我们使用电脑聊天时发送内容都是使用回车键发送内容,使用ctrlshiftalt等和回车键的组合键将输入框的文本进行换行处理。所以接下来要实现的就是对键盘事件的监听,基于事件进行发送内容和内容换行输入处理。


首先是事件的监听,uniapp不支持keydown的事件监听,所以这里使用了原生JS做监听处理,为了避免重复监听,对每次开始监听前先进行移除事件的监听,代码如下:


this.$refs.textarea.$el.removeEventListener('keydown', this.textareaKeydownHandle)
this.$refs.textarea.$el.addEventListener('keydown', this.textareaKeydownHandle)

然后是对textareaKeydownHandle方法的实现,这里需要注意的是组合键对内容换行的处理,需要获取到当前光标的位置,使用textarea.selectionStart可获取,基于光标位置增加一个换行\n的输入即可实现换行,核心代码如下:


const cursorPosition = textarea.selectionStart;
if(
(e.keyCode == 13 && e.ctrlKey) ||
(e.keyCode == 13 && e.metaKey) ||
(e.keyCode == 13 && e.shiftKey) ||
(e.keyCode == 13 && e.altKey)
){
// 换行
this.content = `${this.content.substring(0, cursorPosition)}\n${this.content.substring(cursorPosition)}`
}else if(e.keyCode == 13){
// 发送
this.onSend();
e.preventDefault();
}

高度自适应


当多行输入内容时输入框所占据的高度增加,导致页面实际内容区域的高度减小,如果不进行动态处理会导致实际内容会被遮挡。如下图所示,红色区域就是需要动态处理的高度。



主要需要处理的场景就是输入内容行数变化的时候和用户粘贴文本的时候,这两种情况都会基于当前的可视行数计算输入框的高度,那么内容区域的高度就好计算了,使用整个窗口的高度减去输入框的高度和其他固定的高度如导航高度和底部安全距离高度即是真实内容的高度。


this.contentHeight = this.windowHeight - this.navBarHeight - this.fixedBottomHeight - this.textareaHeight;

最后


到此整个输入框的体验优化核心实现过程就结束了,增加了多行输入,组合键换行输入内容,键盘发送内容,整体内容高度自适应等。整体实现过程的细节功能点还是比较多,有实现过类似需求的同学欢迎留言交流~


看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~


作者:南城FE
来源:juejin.cn/post/7267791228872753167
收起阅读 »

微信小程序 折叠屏适配

web
最近维护了将近的一年的微信小程序(某知名企业),突然提出要兼容折叠屏,这款小程序主要功能一些图表汇总展示,也就是专门给一些领导用的,也不知道为啥领导们为啥突然喜欢用折叠屏手机了,一句话需求,苦的还是咱们程序员,但没办法,谁让甲方是爸爸呢,硬着头皮改吧,好在最后...
继续阅读 »

最近维护了将近的一年的微信小程序(某知名企业),突然提出要兼容折叠屏,这款小程序主要功能一些图表汇总展示,也就是专门给一些领导用的,也不知道为啥领导们为啥突然喜欢用折叠屏手机了,一句话需求,苦的还是咱们程序员,但没办法,谁让甲方是爸爸呢,硬着头皮改吧,好在最后解决了,因为是甲方内部使用的小程序,这里不便贴图,但有官方案例图片,以供参考


查看了微信官网
大屏适配
响应显示区域变化


启用大屏模式


从小程序基础库版本 2.21.3 开始,在 Windows、Mac、车机、安卓 WMPF 等大屏设备上运行的小程序可以支持大屏模式。可参考小程序大屏适配指南。方法是:在 app.json 中添加 "resizable": true


看到这里我心里窃喜,就加个配置完事了?这也太简单了,但后面证明我想简单了,
主要有两大问题:



  • 1 尺寸不同的情况下内容展示效果兼容问题

  • 2 预览版和体验版 大屏模式冷启动会生效,但热启动 和 菜单中点击重新进入小程、授权操作,会失效变成窄屏


解决尺寸问题


因为css的长度单位大部分用的 rpx,窄屏和宽屏展示差异出入较大,别说客户不认,自己这关就过不了,简直都不忍直视,整个乱成一片,尤其登录页,用了定位,更是乱上加乱。


随后参考了官方的文档 小程序大屏适配指南自适应布局,方案对于微信小程序原生开发是可行的,但这个项目用的 uni-app开发的,虽然uni-app 也有对应的响应式布局组件,再加上我是个比较爱偷懒的人(甲方给的工期事件也有限制),不可能花大量时间把所有也页面重新写一遍布局,这是不现实的。


于是又转战到uni-app官网寻找解决方案 uni-app宽屏适配指南


内容缩放拉伸的处理 这一段中提出了两个策略



  • 1.局部拉伸:页面内容划分为固定区域和长宽动态适配区域,固定区域使用固定的px单位约定宽高,长宽适配区域则使用flex自动适配。当屏幕大小变化时,固定区域不变,而长宽适配区域跟着变化

  • 2.等比缩放:根据页面屏幕宽度缩放。rpx其实属于这种类型。在宽屏上,rpx变大,窄屏上rpx变小。


随后看到这句话特别符合我的需求,哈哈 省事 省事 省事


策略2省事,设计师按750px屏宽出图,程序员直接按rpx写代码即可。但策略2的实际效果不如策略1好。程序员使用策略1,分析下界面,设定好局部拉伸区域,这样可以有更好的用户体验


具体实现


1.配置 pages.json 的 globeStyle


{
"globalStyle": {
"rpxCalcMaxDeviceWidth": 1200, // rpx 计算所支持的最大设备宽度,单位 px,默认值为 960
"rpxCalcBaseDeviceWidth": 375, // rpx 计算使用的基准设备宽度,设备实际宽度超出 rpx 计算所支持的最大设备宽度时将按基准宽度计算,单位 px,默认值为 375
"rpxCalcIncludeWidth": 750 // rpx 计算特殊处理的值,始终按实际的设备宽度计算,单位 rpx,默认值为 750
},
}

2.单位兼容


还有一点官方也提出来了很重要,那就很多时候 会把宽度750rpx 当成100% 使用,这在宽屏的设备上就会有问题, uniapp给了两种解决方案



  • 750rpx 改为100%

  • 另一种是配置rpxCalcIncludeWidth,设置某个特定数值不受rpxCalcMaxDeviceWidth约束


想要用局部拉伸:页面内容划分为固定区域和长宽动态适配区域”的策略,单位必须用px


添加脚本


项目根目录新增文件 postcss.config.js 内容如下。则在编译时,编译器会自动转换rpx单位为px。


// postcss.config.js

const path = require('path')
module.exports = {
parser: 'postcss-comment',
plugins: {
'postcss-import': {
resolve(id, basedir, importOptions) {
if (id.startsWith('~@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(3))
} else if (id.startsWith('@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(2))
} else if (id.startsWith('/') && !id.startsWith('//')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(1))
}
return id
}
},
'autoprefixer': {
overrideBrowserslist: ["Android >= 4", "ios >= 8"],
remove: process.env.UNI_PLATFORM !== 'h5'
},
// 借助postcss-px-to-viewport插件,实现rpx转px,文档:https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md
// 以下配置,可以将rpx转换为1/2的px,如20rpx=10px,如果要调整比例,可以调整 viewportWidth 来实现
'postcss-px-to-viewport': {
unitToConvert: 'rpx',
viewportWidth: 200,
unitPrecision: 5,
propList: ['*'],
viewportUnit: 'px',
fontViewportUnit: 'px',
selectorBlackList: [],
minPixelValue: 1,
mediaQuery: false,
replace: true,
exclude: undefined,
include: undefined,
landscape: false
},
'@dcloudio/vue-cli-plugin-uni/packages/postcss': {}
}
}


大屏模式失效问题


下面重头戏来了,这期间经历 蜿蜒曲折 ,到头来发现都是无用功,我自己都有被wx蠢到发笑,唉,


样式问题解决后 开始着重钻研 大屏失效的问题,但看了官方的多端适配示例demo,人家的就是好的,那就应该有解决办法,于是转战github地址 下项目,谁知这项目暗藏机关,各种报错,让你跑不起来。。。。,让我一度怀疑腾讯也这么拉跨


还好issues 区一位大神有解决办法 感兴趣的老铁可以去瞅瞅
image


1693664649860.jpg


另外 微信小程序开发工具需要取消这两项,最后当项目跑起来后我还挺开心,模拟器上没有问题,但用真机预览的时候我啥眼了,还是窄屏,偶尔可以大屏,后面发现 冷启动是大屏,热启动和点击右上角菜单中的重新进入小程序按钮都会自己变成窄屏幕


官方案例.gif批量更新.gif

这是官方的项目啊,为啥人家的可以,我本地跑起来却不可以,让我一度怀疑这里有内幕,经过几轮测试还是不行,于是乎,我开始了各种询问查资料,社区、私聊、评论、github issues,最后甚至 统计出来了 多端适配示例demo 开发者的邮箱 挨个发了邮件,但都结果无一例外,全部石沉大海


1693666642117.jpgwx-github-issues-110.jpg
私聊.jpg评论.jpg
wx-mini-dev.jpgimage.png

结果就是,没有办法了,想看看是不是只有预览和体验版有问题,后面发布到正式版后,再看居然没问题了,就是这么神奇,也是无语!!!! 原来做了这么多无用功。。。。


作者:iwhao
来源:juejin.cn/post/7273764921456492581
收起阅读 »

Moshi:现代 Json 解析库全解析

json 解析框架,很容易想到 Gson、fastJson 等。而这些流行框架对 kotlin 的支持并不好,而Moshi 天生对 kotlin 友好。 前言 Gson 通过反射反序列化数据,Java 类默认有无参构造函数,对于默认参数能够很好的支持。对于 k...
继续阅读 »

json 解析框架,很容易想到 Gson、fastJson 等。而这些流行框架对 kotlin 的支持并不好,而Moshi 天生对 kotlin 友好。


前言


Gson 通过反射反序列化数据,Java 类默认有无参构造函数,对于默认参数能够很好的支持。对于 kotlin ,我们经常使用的 data class,其往往没有无参构造函数,Gson 便会通过 UnSafe 的方式创建实例,成员无法正常初始化默认值。为了勉强能用,只能将构造参数都加上默认值才行,不过这种兼容方式太过隐晦,有潜在的维护风险。


另外,Gson 无法支持 kotlin 空安全特性。定义为不可空且无默认值的字段,在没有该字段对应的 json 数据时会被赋值为 null,这可能导致使用时引发空指针问题。


Moshi


Moshi 是一个适用于 Android、Java 和 Kotlin 的现代 JSON 库。它可以轻松地将 JSON 解析为 Java 和 Kotlin 类。


val json: String = ...

val moshi: Moshi = Moshi.Builder().build()
val jsonAdapter: JsonAdapter = moshi.adapter()

val person = jsonAdapter.fromJson(json)

通过类型适配器 JsonAdapter 可以对数据类型 T 进行序列化/反序列化操作,即 toJsonfromJson 方法。


内置类型适配器


moshi 内置支持以下类型的类适配器:

  • 基本类型
  • Arrays, Collections, Lists, Sets, Maps
  • Strings
  • Enums


直接或间接由它们构成的自定义数据类型都可以直接解析。


反射 OR 代码生成


moshi 支持反射和代码生成两种方式进行 Json 解析。


反射的好处是无需对数据类做任何变动,可以解析 private 和 protected 成员,缺点是引入反射相关库,包体积增大2M多,且反射在性能上稍差。


代码生成的好处是速度更快,缺点是需要对数据类添加注解,无法处理 private 和 protected 成员,用于编译时生成代码,影响编译速度,且注解使用越来越多生成的代码也会越来越多。


反射方案依赖:


implementation("com.squareup.moshi:moshi-kotlin:1.14.0")

代码生成方案依赖(ksp):


plugins {
id("com.google.devtools.ksp").version("1.6.10-1.0.4") // Or latest version of KSP
}

dependencies {
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.14.0")
}

使用代码生成,需要使用注解 @JsonClass(generateAdapter = true) 修饰数据类:


@JsonClass(generateAdapter = true)
data class Person(
val name: String
)

使用反射时,需要添加 KotlinJsonAdapterFactoryMoshi.Builder


val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()

💡 注意:这里要使用 addLast 添加 KotlinJsonAdapterFactory,因为 Adapter 是按添加顺序排列和使用的,如果有自定义的 Adapter,为确保自定义的始终在前,建议通过 addLastKotlinJsonAdapterFactory 始终放在最后。


我们目前使用的是反射方案,主要考虑到侵入性低,数据类几乎无改动。


其实也可以两种方案都使用,Moshi 会优先使用代码生成的 Adapter,没有的话则走反射。


解析 JSON 数组


对于 json 数据:


[
{
"rank": "4",
"suit": "CLUBS"
},
{
"rank": "A",
"suit": "HEARTS"
}
]

解析:


String cardsJsonResponse = ...;
Type type = Types.newParameterizedType(List.class, Card.class);
JsonAdapter> adapter = moshi.adapter(type);
List cards = adapter.fromJson(cardsJsonResponse);

和 Gson 类似,为了运行时获取泛型信息,稍微麻烦点,可以定义扩展函数简化用法:


inline fun <reified T> Moshi.listAdapter(): JsonAdapter> {
val type = Types.newParameterizedType(List::class.java, T::class.java)
return adapter(type)
}

简化后:


String cardsJsonResponse = ...
val cards = moshi.listAdapter().fromJson(cardsJsonResponse)

自定义字段名


如果Json 中字段名和数据类中字段名不一致,或 json 中有空格,可以使用 @Json 注解修饰别名。


{
"username": "jesse",
"lucky number": 32
}

class Player {
val username: String
@Json(name = "lucky number") val luckyNumber: Int

...
}

忽略字段


使用 @Json(ignore = true) 可以忽略字段的解析,java 中的 @Transient 注解也可以。


class BlackjackHand(...) {
@Json(ignore = true)
var total: Int = 0

...
}

Java 支持


Moshi 同样支持 Java。需要注意的是,和 Gson 一样,Java 类需要有无参构造方法,否则成员变量的默认值无法生效。


public final class BlackjackHand {
private int total = -1;
...

public BlackjackHand(Card hidden_card, List visible_cards) {
...
}
}

如上,total 的默认值会为 0.


另外,和 Gson 不一样的是,Moshi 并不支持 JsonElement 这种中间产物,它只支持内置类型如 List、Map。


自定义 JsonAdapter


如果 json 的数据格式和我们想要的不一样,就需要我们自定义 JsonAdapter 来解析了。有意思的是,任何拥有 @Json@ToJson 注解的类都可以成为 Adapter,无需继承 JsonAdapter。


例如 json 格式:


{
"title": "Blackjack tournament",
"begin_date": "20151010",
"begin_time": "17:04"
}

目标数据类定义:


class Event(
val title: String,
val beginDateAndTime: String
)

我们希望 json 中日期 begin_date 和时间 begin_time 组成 beginDateAndTime 字段。moshi 支持我们在 json 和目标数据转换间定义一个中间类,json 和中间类转换后再转换为最终类型。


定义中间类型,本例中即和 json 匹配的数据类型:


class EventJson(
val title: String,
val begin_date: String,
val begin_time: String
)

定义 Adapter :


class EventJsonAdapter {
@FromJson
fun eventFromJson(eventJson: EventJson): Event {
return Event(
title = eventJson.title,
beginDateAndTime = "${eventJson.begin_date} ${eventJson.begin_time}"
)
}

@ToJson
fun eventToJson(event: Event): EventJson {
return EventJson(
title = event.title,
begin_date = event.beginDateAndTime.substring(0, 8),
begin_time = event.beginDateAndTime.substring(9, 14),
)
}
}

将 adapter 注册到 moshi:


val moshi = Moshi.Builder()
.add(EventJsonAdapter())
.build()

这样就可以使用 moshi 直接将 json 转换成 Event 了。本质是将 Json 和目标数据的相互转换加了个中间步骤,先转换为中间产物,再转为最终 Json 或数据实例。


@JsonQualifier:自定义字段类型解析


如下 json,color 为十六进制 rgb 格式的字符串:


{
"width": 1024,
"height": 768,
"color": "#ff0000"
}

数据类,color 为 Int 类型:


class Rectangle(
val width: Int,
val height: Int,
val color: Int
)

Json 中 color 字段类型是 String,数据类同名字段类型为 Int,除了上面介绍的自定义 JsonAdapter 外,还可以自定义同一数据的不同数据类型间的转换。


首先自定义注解:


@Retention(RUNTIME)
@JsonQualifier
annotation class HexColor

使用注解修饰字段:


class Rectangle(
val width: Int,
val height: Int,
@HexColor val color: Int
)

自定义 Adapter:


/** Converts strings like #ff0000 to the corresponding color ints.  */
class ColorAdapter {
@ToJson fun toJson(@HexColor rgb: Int): String {
return "#x".format(rgb)
}

@FromJson @HexColor fun fromJson(rgb: String): Int {
return rgb.substring(1).toInt(16)
}
}

通过这种方式,同一字段可以有不同的解析方式,可能不多见,但的确有用。


适配器组合


举个例子:


class UserKeynote(
val type: ResourceType,
val resource: KeynoteResource?
)

enum class ResourceType {
Image,
Text
}

sealed class KeynoteResource(open val id: Int)

data class Image(
override val id: Int,
val image: String
) : KeynoteResource(id)

data class Text(
override val id: Int,
val text: String
) : KeynoteResource(id)

UserKeynote 是目标类,其中的 KeynoteResource 可能是 ImageText ,具体是哪个需要根据 type 字段来决定。也就是说 UserKeynote 的解析需要 Image 或 Text 对应的 Adapter 来完成,具体是哪个取决于 type 的值。


显然自带的 Adapter 不能满足需求,需要自定义 Adapter。


先看下 Adapter 中签名要求(参见源码 AdapterMethodsFactory.java):


@FromJson


 R fromJson(JsonReader jsonReader) throws 

R fromJson(JsonReader jsonReader, JsonAdapter delegate, ) throws

R fromJson(T value) throws

@ToJson


 void toJson(JsonWriter writer, T value) throws 

void toJson(JsonWriter writer, T value, JsonAdapter delegate, ) throws

R toJson(T value) throws

前面分析了我们需要借助 Image 或 Text 对应的 Adapter,所以使用第二组函数签名:


class UserKeynoteAdapter {
private val namesOption = JsonReader.Options.of("type")

@FromJson
fun fromJson(
reader:
JsonReader,
imageJsonAdapter:
JsonAdapter<Image>,
textJsonAdapter:
JsonAdapter<Text>
)
: UserKeynote {
// copy 一份 reader,得到 type
val newReader = reader.peekJson()
newReader.beginObject()
var type: String? = null
while (newReader.hasNext()) {
if (newReader.selectName(namesOption) == 0) {
type = newReader.nextString()
}
newReader.skipName()
newReader.skipValue()
}
newReader.endObject()

// 根据 type 做解析
val resource = when (type) {
ResourceType.Image.name -> {
imageJsonAdapter.fromJson(reader)
}

ResourceType.Text.name -> {
textJsonAdapter.fromJson(reader)
}

else -> throw IllegalArgumentException("unknown type $type")
}
return UserKeynote(ResourceType.valueOf(type), resource)
}

@ToJson
fun toJson(
writer:
JsonWriter,
userKeynote:
UserKeynote,
imageJsonAdapter:
JsonAdapter<Image>,
textJsonAdapter:
JsonAdapter<Text>
)
{
when (userKeynote.resource) {
is Image -> imageJsonAdapter.toJson(writer, userKeynote.resource)
is Text -> textJsonAdapter.toJson(writer, userKeynote.resource)
null -> {}
}
}
}

函数接收一个 JsonReader / JsonWriter 以及若干 JsonAdapter,可以认为该 Adapter 由其他多个 Adapter 组合完成。这种委托的思路在 Moshi 中很常见,比如内置类型 List 的解析,便是委托给了 T 的适配器,并重复调用。


限制



  • 不要 Kotlin 类继承 Java 类

  • 不要 Java 类继承 Kotlin 类


这是官方强调不要做的,如果你那么做了,发现还没问题,不要侥幸,建议修改,毕竟有有维护风险,且会误导其他维护的人以为这样是可靠合理的。


作者:Aaron_Wang
来源:juejin.cn/post/7273516671575113743
收起阅读 »

详解JS判断页面是在手机端还是在PC端打开的方法

web
下面详细介绍一下如何判断页面是在手机端还是在PC端打开,并提供两条示例说明。 方法一:使用UA判断 UA(UserAgent)是指HTTP请求头中的一部分,用于标识客户端的一些信息,比如用户的设备类型、浏览器型号等等。因此,我们可以通过判断UA中的关键字来确定...
继续阅读 »

下面详细介绍一下如何判断页面是在手机端还是在PC端打开,并提供两条示例说明。


方法一:使用UA判断


UA(UserAgent)是指HTTP请求头中的一部分,用于标识客户端的一些信息,比如用户的设备类型、浏览器型号等等。因此,我们可以通过判断UA中的关键字来确定页面访问者的设备类型。下面是实现的代码:


const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

if (isMobile) {
console.log('当前在手机端');
} else {
console.log('当前在PC端');
}

代码解析:


首先,我们使用正则表达式匹配navigator.userAgent中是否包含iPhoneiPadiPodAndroid这些关键字,如果匹配成功,则说明当前是在移动端。如果匹配失败,则说明当前是在PC端。


需要注意的是,该方法并不100%准确,因为用户可以使用PC浏览器模拟手机UA,也有可能使用移动端浏览器访问PC网站。


方法二:使用媒体查询判断


媒体查询是CSS3的一个新特性,可以根据不同的媒体类型(比如设备屏幕的宽度、高度、方向等)来设置不同的CSS样式。我们可以利用媒体查询来判断页面是在手机端还是在PC端打开。下面是实现的代码:


<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title>判断页面是在手机端还是在PC端</title>
<style>
/* 默认样式 */
p {
font-size: 24px;
color: yellow;
}
/* 移动端样式 */
@media (max-width: 767px) {
p {
font-size: 20px;
color: green;
}
}
</style>
</head>
<body>
<p>测试内容</p>
</body>
</html>

代码解析:


在CSS中,我们使用@media关键字定义了一个媒体查询,当浏览器宽度小于等于767px的时候,p元素的字体大小和颜色都会发生改变,从而实现了对移动端的识别。如果浏览器宽度大于767px,则会使用默认样式。


需要注意的是,该方法只能判断设备的屏幕宽度,不能确定设备的真实类型,因此并不太准确。


总的来说,两种方法各有优缺点,具体选择哪种方法要根据自己的需求和场景来决定。一般来说,如果只是想简单地判断页面访问者的设备类型,使用第一种方法即可。如果需要根据设备类型来优化网站的布局和样式,可以使用第二种方法。


作者:RuiRay
来源:juejin.cn/post/7273746154642014262
收起阅读 »

Android 多种支付方式的优雅实现!

1.场景 App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。 并且可以添加的支付方式也是不确定的,由后台动态下发。 如下图所示: 根据上图 ui...
继续阅读 »

1.场景


App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。


并且可以添加的支付方式也是不确定的,由后台动态下发。


如下图所示:


image.png


根据上图 ui 理一下执行流程:



  1. 点击不同的添加支付方式 item。

  2. 进入相对应的添加支付方式流程(表单页面、webview、弹框之类的)。

  3. 在第三方回调里面根据不同的支付方式执行不同的操作。

  4. 调用后台接口查询添加是否成功。

  5. 根据接口结果展示不同的成功或者失败的ui.


2.以前的实现方式


用一个 Activity 承载,上述所有的流程都在 Activity 中。Activity 包含了列表展示、多种支付方式的实现和 ui。


伪代码如下:


class AddPaymentListActivity : AppCompatActivity(R.layout.activity_add_card) {

private val addPaymentViewModel : AddPaymentViewModel = ...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPaymentViewModel.checkPaymentStatusLiveData.observer(this) { isSuccess ->
// 从后台结果判断是否添加成功
if (isSuccess) {
addCardSuccess(paymentType)
} else {
addCardFailed(paymentType)
}
}
}

private fun clickItem(paymentType: PaymentType) {
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> //执行添加谷歌支付流程
PaymentType.ADD_PAY_PEL-> //执行添加PayPel支付流程
PaymentType.ADD_ALI_PAY-> //执行添加支付宝支付流程
PaymentType.ADD_STRIPE-> //执行添加Stripe支付流程
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (resultCode) {
PaymentType.ADD_GOOGLE_PAY -> {
// 根据第三方回调的结果,拿到key
// 根据key调用后台的Api接口查询是否添加成功
}
PaymentType.ADD_PAY_PEL -> // 同上
// ...
}
}

private fun addCardSuccess(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式成功,展示成功的ui,然后执行下一步操作
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

private fun addCardFailed(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式失败,展示失败的ui
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

enum class PaymentType {
ADD_GOOGLE_PAY, ADD_PAY_PEL, ADD_ALI_PAY, ADD_STRIPE
}

}

虽然看起来根据 paymentType 来判断,逻辑条理也还过得去,但是实际上复杂度远远不止如此。


• 不同的支付方式跳转的页面相差很大。


• 结果的回调获取也相差很大,并不是所有的都在onActivityResult中。


• 成功和失败实际上也不能统一来处理,里面包含很多的if…else…判断。


• 如果支付方式是后台动态下发的,处理起来判断逻辑就更多了。


此外,最大的问题:扩展性问题。


当新来一种支付方式,例如微信支付之类的,改动代码就很大了,基本就是将整个Activity中的代码都要改动。可以说上面这种方式的可扩展性为零,就是简单的将代码都揉在一起。


3.优化后的代码


要想实现高内聚低耦合,最简单的就是套用常见的设计模式,回想一下,发现策略模式+简单工厂模式非常这种适合这种场景。


先看下优化后的代码:


class AddPlatformActivity : BaseActivity() {

private var addPayPlatform: IAddPayPlatform? = null

private fun addPlatform(payPlatform: String) {
// 将后台返回的支付平台字符串变成枚举类
val platform: PayPlatform = getPayPlatform(payPlatform) ?: return
addPayPlatform = AddPayPlatformFactory.getCurrentPlatform(this, platform)
addPayPlatform?.getLoadingLiveData()?.observe(this@AddPlatformActivity) { showLoading ->
if (showLoading) startLoading() else stopLoading()
}
addPayPlatform?.addPayPlatform(AddCardParameter(platform))
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// 将onActivityResult的回调转接到需要监听的策略类里面
addPayPlatform?.thirdAuthenticationCallback(requestCode, resultCode, data)
}
}

4.策略模式


意图: 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。


主要解决: 在有多种算法相似的情况下,使用if…else所带来的复杂和难以维护。


何时使用: 一个系统有许多许多类,而区分它们的只是他们直接的行为。


如何解决: 将这些算法封装成一个一个的类,任意地替换。


关键代码: 实现同一个接口。


**优点: **


1、算法可以自由切换。


2、避免使用多重条件判断。


3、扩展性良好。


缺点


1、策略类会增多。


2、所有策略类都需要对外暴露。


**使用场景: **


1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。


2、一个系统需要动态地在几种算法中选择一种。


3、如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句来实现。


5.需要实现的目标


5.1 解耦宿主 Activity


现在宿主Activity中代码太重了,包含多种支付方式实现,还有列表ui的展示,网络请求等。


现在目标是将 Activity 中的代码拆分开来,让宿主 Activity 变得小而轻。


如果产品说新增一种支付方式,只需要改动很少的代码,就可以轻而易举的实现。


5.2 抽取成独立的模块


因为公司中有可能存在多个项目,支付模块的分层应该处于可复用的层级,以后很有可能将其封装成一个独立的 mouble,给不同的项目使用。


现在代码全在 Activity 中,以后若是抽取 mouble 的话,相当于整个需求重做。


5.3 组件黑盒


"组件黑盒"这个名词是我自己的一个定义。大致意思:



将一个 View 或者一个类进行高度封装,尽可能少的暴露public方法给外部使用,自成一体。




业务方在使用时,可以直接黑盒使用某个业务组件,不必关心其中的逻辑。




业务方只需要一个简单的操作,例如点击按钮调用方法,然后逻辑都在组件内部实现,组件内处理外部事件的所有操作,例如:Loading、请求网络、成功或者失败。




业务方都不需要知道组件内部的操作,做到宿主和组件的隔离。




当然这种处理也是要分场景考虑的,其中一个重点就是这个组件是偏业务还是偏功能,也就是是否要将业务逻辑统一包进组件,想清楚这个问题后,才能去开发一个业务组件。 摘自xu’yi’sheng博客。xuyisheng.top/author/xuyi…



因为添加支付方式是一个偏业务的功能,我的设计思路是:


外部 Activity 点击添加对应的支付方式,将支付方式的枚举类型和支付方式有关的参数通过传递,然后不同的策略类组件执行自己的添加逻辑,再通过一层回调将第三方支付的回调从 Activity 中转接过来,每个策略类内部处理自己的回调操作,具体的策略类自己维护成功或者失败的ui。


6.具体实现


6.1 定义顶层策略接口


interface IAddPayPlatform {

fun addPayPlatform(param: AddCardParameter)

fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?)

fun addCardFailed(message: String?)

fun addCardSuccess()
}

6.2 通用支付参数类


open class AddCardParameter(val platform: PayPlatform)

class AddStripeParameter(val card: Card, val setPrimaryCard: Boolean, platform: PayPlatform)
: AddCardParameter(platform = PayPlatform.Stripe)

因为有很多种添加支付方式,不同的支付方式对应的参数都不一样。


所以先创建一个通用的卡片参数基类AddCardParameter, 不同的支付方式去实现不同的具体参数。这样的话策略接口就可以只要写一个添加卡片的方法addPayPlatform(paramAddCardParameter)


6.3 Loading 的处理


因为我想实现的是黑盒组件的效果,所有添加卡片的loading也是封装在每一个策略实现类里面的。


Loading的出现和消失这里有几种常见的实现方式:


• 传递BaseActivity的引用,因为我的loading有关的方法是放在BaseActivity中,这种方式简单但是会耦合BaseActivity。


• 使用消息事件总线,例如EventBus之类的,这种方式解耦强,但是消息事件不好控制,还要添加多余的依赖库。


• 使用LiveData,在策略的通用接口中添加一个方法返回Loading的LiveData, 让宿主Activity自己去实现。


interface IAddPayPlatform {
// ...
fun getLoadingLiveData(): LiveData<Boolean>?
}

6.4 提取BaseAddPayStrategy


因为每一个添加卡的策略会存在很多相同的代码,这里我抽取一个BaseAddPayStrategy来存放模板代码。


需要实现黑盒组件的效果,宿主Activity中都不需要去关注添加支付方式是不是存在网络请求这一个过程,所以网络请求也分装在每一个策略实现类里面。


abstract class BaseAddPayStrategy(val activity: AppCompatActivity, val platform: PayPlatform) : IAddPayPlatform {

private val loadingLiveData = SingleLiveData<Boolean>()

protected val startActivityIntentLiveData = SingleLiveData<Intent>()

override fun getLoadingLiveData(): LiveData<Boolean> = loadingLiveData

protected fun startLoading() = loadingLiveData.setValue(true)

protected fun stopLoading() = loadingLiveData.setValue(false)

private fun reloadWallet() {
startLoading()
// 添加卡片完成后,重新刷新钱包数据
}

override fun addCardSuccess() {
reloadWallet()
}

override fun addCardFailed(message: String?) {
stopLoading()
if (isEWalletPlatform(platform)) showAddEWalletFailedView() else showAddPhysicalCardFailedView(message)
}

/**
* 添加实体卡片失败展示ui
*/

private fun showAddPhysicalCardFailedView(message: String?) {
showSaveErrorDialog(activity, message)
}

/**
* 添加实体卡片成功展示ui
*/

private fun showAddPhysicalCardSuccessView() {
showCreditCardAdded(activity) {
activity.setResult(Activity.RESULT_OK)
activity.finish()
}
}

private fun showAddEWalletSucceedView() {
// 添加电子钱包成功后的执行
activity.setResult(Activity.RESULT_OK)
activity.finish()
}

private fun showAddEWalletFailedView() {
// 添加电子钱包失败后的执行
}

// ---默认空实现,有些支付方式不需要这些方法---
override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?) = Unit

override fun getStartActivityIntent(): LiveData<Intent> = startActivityIntentLiveData
}

6.5 具体的策略类实现


通过传递过来的AppCompatActivity引用获取添加卡片的ViewModel实例AddPaymentViewModel,然后通过AddPaymentViewModel去调用网络请求查询添加卡片是否成功。


class AddXXXPayStrategy(activity: AppCompatActivity) : BaseAddPayStrategy(activity, PayPlatform.XXX) {

protected val addPaymentViewModel: AddPaymentViewModel by lazy {
ViewModelProvider(activity).get(AddPaymentViewModel::class.java)
}

init {
addPaymentViewModel.eWalletAuthorizeLiveData.observeState(activity) {

onSuccess { addCardSuccess()}

onError { addCardFailed(it.detailed) }
}
}

override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, result: Intent?) {
val uri: Uri = result?.data ?: return
if (uri.host == "www.xxx.com") {
uri.getQueryParameter("transactionId")?.let {
addPaymentViewModel.confirmEWalletAuthorize(platform.name, it)
}
}
}

override fun addPayPlatform(param: AddCardParameter) {
startLoading()
addPaymentViewModel.addXXXCard(param)
}
}

7.简单工厂进行优化


因为我不想在Activity中去引用每一个具体的策略类,只想引用抽象接口类IAddPayPlatform, 这里通过一个简单工厂来优化。


object AddPayPlatformFactory {


fun setCurrentPlatform(activity: AppCompatActivity, payPlatform: PayPlatform): IAddPayPlatform? {
return when (payPlatform) {
PayPlatform.STRIPE -> AddStripeStrategy(activity)
PayPlatform.PAYPAL -> AddPayPalStrategy(activity)
PayPlatform.LINEPAY -> AddLinePayStrategy(activity)
PayPlatform.GOOGLEPAY -> AddGooglePayStrategy(activity)
PayPlatform.RAPYD -> AddRapydStrategy(activity)
else -> null
}
}

}


8.再增加一种支付方式


如果再增加一种支付方式,宿主Activity中的代码都可以不要改动,只需要新建一个新的策略类,实现顶层策略接口即可。


这样,不管是删除还是新增一种支付方式,维护起来就很容易了。


策略模式的好处就显而易见了。



今日分享到此结束,对你有帮助的话,点个赞再走呗,每日一个面试小技巧




关注公众号:Android老皮

解锁  《Android十大板块文档》 ,让学习更贴近未来实战。已形成PDF版



内容如下



1.Android车载应用开发系统学习指南(附项目实战)

2.Android Framework学习指南,助力成为系统级开发高手

3.2023最新Android中高级面试题汇总+解析,告别零offer

4.企业级Android音视频开发学习路线+项目实战(附源码)

5.Android Jetpack从入门到精通,构建高质量UI界面

6.Flutter技术解析与实战,跨平台首要之选

7.Kotlin从入门到实战,全方面提升架构基础

8.高级Android插件化与组件化(含实战教程和源码)

9.Android 性能优化实战+360°全方面性能调优

10.Android零基础入门到精通,高手进阶之路



作者:派大星不吃蟹
来源:juejin.cn/post/7274475842998157353
收起阅读 »

强大的css计数器,你确定不来看看?

web
强大的 css 计数器 css content 属性有很多实用的用法,这其中最为强大的莫过于是计数器了,它甚至可以实现连 javascript 都不能够实现的效果,下面我们一起来研究一下吧。 css 计数器主要有 3 个关键点需要掌握。如下: 首先需要一个计...
继续阅读 »

强大的 css 计数器


css content 属性有很多实用的用法,这其中最为强大的莫过于是计数器了,它甚至可以实现连 javascript 都不能够实现的效果,下面我们一起来研究一下吧。


css 计数器主要有 3 个关键点需要掌握。如下:



  1. 首先需要一个计数器的名字,这个名字由使用者自己定义。

  2. 计数器有一个计数规则,比如是 1,2,3,4...这样的递增方式,还是 1,2,1,2...这样的连续递增方式。

  3. 计数器的使用,即定义好了一个计数器名字和计数规则,我们就需要去使用它。


以上 3 个关键点分别对应的就是 css 计数器的 counter-reset 属性,counter-increment 属性,和 counter()/counters()方法。下面我们依次来介绍这三个玩意儿。


counter-reset 属性


counter-reset 属性叫做计数器重置,对应的就是创建一个计数器名字,如果可以,顺便也可以告诉计数器的计数起始值,也就是从哪个值开始计数,默认值是 0,注意是 0,而不是 1。例如以下一个示例:


html 代码如下:


<p>开始计数,计数器名叫counter</p>
<p class="counter"></p>

css 代码如下:


.counter {
counter-reset: counter;
}

.counter::before {
content: counter(counter);
}

在浏览器中运行以上示例,你会看到如下图所示:


counter-1.png


可以看到计数器的初始值就是 0,现在我们修改一下 css 代码,如下所示:


.counter {
counter-reset: counter 1;
}

在浏览器中运行以上示例,你会看到如下图所示:


counter-2.png


这次我们指定了计数器的初始值 1,所以结果就是 1,计数器的初始值同样也可以指定成小数,负数,如-2,2.99 之类,只不过 IE 和 FireFox 浏览器都会认为是不合法的数值,当做默认值 0 来处理,谷歌浏览器也会直接显示负数,如下图所示:


counter-3.png


低版本谷歌浏览器处理小数的时候是向下取整,比如 2.99 则显示 2,最新版本则当成默认值 0,来处理,如下图所示:


counter-4.png



ps: 当然不推荐指定初始值为负数或者小数。



你以为到这里就完了吗?还没有,计数器还可以指定多个,每一个计数器之间用空格隔开,比如以下代码:


.counter {
counter-reset: counter1 1 counter2 2;
}

.counter::before {
content: counter(counter1) counter(counter2);
}

在浏览器中运行以上示例,你会看到如下图所示:


counter-5.png


除此之外,计数器名还可以指定为 none 和 inherit,也就是取消计数和继承计数器,这没什么好说的。


counter-increment


顾名思义,该属性就是计数器递增的意思,也就是定义计数器的计数规则,值为计数器的名字,可以是一个或者多个,并且也可以指定一个数字,表示计数器每次变化的数字,如果不指定,默认就按照 1 来变化。比如以下代码:


.counter {
counter-reset: counter 1;
counter-increment: counter;
}

得到的结果就是: 1 + 1 = 2。如下图所示:


counter-6.png


再比如以下代码:


.counter {
counter-reset: counter 2;
counter-increment: counter 3;
}

得到的结果就是: 2 + 3 = 5,如下图所示:


counter-7.png


由此可见,计数器的规则就是: 计数器名字唯一,每指定一次计数规则,计数器就会加一,每指定二次计数规则,计数器就会加二,……以此类推。


计数规则不仅可以创建在元素上,也可以创建在使用计数器的元素上,比如以下代码:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter);
counter-increment: counter;
}

我们不仅在类名为 counter 元素上创建了一个计数器规则,同样的也在 before 伪元素上创建了一个计数器规则,因此最后的结果就是: 0 + 1 + 1 = 2。如下图所示:


counter-8.png


总而言之,无论位置在何处,只要有 counter-increment,对应的计数器的值就会变化, counter()只是输出而已!计数器的数值变化遵循 HTML 渲染顺序,遇到一个 increment 计数器就变化,什么时候 counter 输出就输出此时的计数值。


除此之外,计数器规则也可以和计数器一样,创建多个计数规则,也是以空格区分,比如以下示例代码:


.counter {
counter-reset: counter1 1 counter2 2;
counter-increment: counter1 2 counter2 3;
}

.counter::before {
content: counter(counter1) counter(counter2);
counter-increment: counter1 4 counter2 5;
}

此时的结果就应该是计数器 1: 1 + 2 + 4 = 7,计数器 2: 2 + 3 + 5 = 10。如下图所示:


counter-9.png


同样的,计数器规则的值也可以是负数,也就是递减效果了,比如以下代码:


.counter {
counter-reset: counter1 1 counter2 2;
counter-increment: counter1 -1 counter2 -3;
}

.counter::before {
content: counter(counter1) counter(counter2);
counter-increment: counter1 2 counter2 5;
}

此时的结果就应该是计数器 1: 1 - 1 + 2 = 2,计数器 2: 2 - 3 + 5 = 4。如下图所示:


counter-10.png


同样的计数规则的值也可以是 none 或者 inherit。


counter


counter 方法类似于 calc,主要用于定义计数器的显示输出,到目前为止,我们前面的示例都是最简单的输出,也就是如下语法:


counter(name); /* name为计数器名 */

实际上还有如下的语法:


counter(name,style);

style 参数和 list-style-type 的值一样,意思就是不仅可以显示数字,还可以显示罗马数字,中文字符,英文字母等等,值如下:


list-style-type: disc | circle | square | decimal | lower-roman | upper-roman |
lower-alpha | upper-alpha | none | armenian | cjk-ideographic | georgian |
lower-greek | hebrew | hiragana | hiragana-iroha | katakana | katakana-iroha |
lower-latin | upper-latin | simp-chinese-informal;

比如以下的示例代码:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter, lower-roman);
}

结果如下图所示:


counter-11.png


再比如以下的示例代码:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter, simp-chinese-informal);
}

结果如下图所示:


counter-12.png


同样的 counter 也可以支持级联,也就是说,一个 content 属性值可以有多个 counter 方法,如:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter) '.' counter(counter);
}

结果如下图所示:


counter-13.png


counters


counters 方法虽然只是比 counter 多了一个 s 字母,但是含义可不一样,counters 就是用来嵌套计数器的,什么意思了?我们平时如果显示列表符号,不可能只是单单显示 1,2,3,4...还有可能显示 1.1,1.2,1.3...前者是 counter 做的事情,后者就是 counters 干的事情。


counters 的语法为:


counters(name, string);

name 就是计数器名字,而第二个参数 string 就是分隔字符串,比如以'.'分隔,那 string 的值就是'.',以'-'分隔,那 string 的值就是'-'。来看如下一个示例:


html 代码如下:


<div class="reset">
<div class="counter">
javascript框架
<div class="reset">
<div class="counter">&nbsp;angular</div>
<div class="counter">&nbsp;react</div>
<div class="counter">
vue
<div class="reset">
<div class="counter">
vue语法糖
<div class="reset">
<div class="counter">&nbsp;@</div>
<div class="counter">&nbsp;v-</div>
<div class="counter">&nbsp;:</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

css 代码如下:


.reset {
counter-reset: counter;
padding-left: 20px;
}

.counter::before {
content: counters(counter, '-') '.';
counter-increment: counter;
}

结果如下图所示:


counter-14.png


这种计数效果在模拟书籍的目录效果时非常实用,比如写文档,会有嵌套标题的情况,还有一个比较重要的点需要说明一下,就是显示 content 计数值的那个 DOM 元素在文档流中的位置一定要在 counter-increment 元素的后面,否则是没有计数效果的。


总而言之,content 计数器是非常强大的,以上都只是很基础的用法,真正掌握还需要大量的实践以及灵感还有创意。


作者:夕水
来源:juejin.cn/post/7275176987358265355
收起阅读 »

面试官:如何防止重复提交订单?

这个问题,在电商领域的面试场景题下,应该算是妥妥的高频问题了,仅次于所谓的“秒杀场景如何实现”。 说个题外话,有段时间“秒杀场景如何实现”这个问题风靡一时,甚至在面试的时候,有些做财务领域、OA领域公司的面试官也都跟风问。 大有一种”无秒杀,不面试“的感觉了。...
继续阅读 »

这个问题,在电商领域的面试场景题下,应该算是妥妥的高频问题了,仅次于所谓的“秒杀场景如何实现”。


说个题外话,有段时间“秒杀场景如何实现”这个问题风靡一时,甚至在面试的时候,有些做财务领域、OA领域公司的面试官也都跟风问。


大有一种”无秒杀,不面试“的感觉了。


重复提交原因


其实原因无外乎两种:



  • 一种是由于用户在短时间内多次点击下单按钮,或浏览器刷新按钮导致。

  • 另一种则是由于Nginx或类似于SpringCloud Gateway的网关层,进行超时重试造成的。


常见解决方案


方案一:提交订单按钮置灰


这种解决方案在注册登录的场景下比较常见,当我们点击”发送验证码“按钮的时候,会进行手机短信验证码发送,且按钮就会有一分钟左右的置灰。


有些经验不太丰富的同学,通常会简单粗暴地把这个方案直接照搬过来。


但这种方案只能解决多次点击下单按钮的问题,对于Nginx或类似于SpringCloud Gateway的超时重试所导致的问题是无能为力的。


当然,这种方案也不是真的没有价值。它可以在高并发场景下,从浏览器端去拦住一部分请求,减少后端服务器的处理压力。


说到底,“下单防重”的问题是属于“接口幂等性”的问题范畴。



幂等性


接口幂等性是指:以相同的参数,对一个接口进行多次调用,所产生的结果和一次调用是完全相同的。


下面的情况就是幂等的:


student.setName("张三");

而这种情况就是非幂等的,因为每次调用,年龄都会增加一岁。


student.increaseAge(1);

现在我们的思路需要切换到幂等性的解决方案来。


同样是幂等性场景,“如何防止重复提交订单” 比 “如何防止订单重复支付” 的解决方案要难一些。


因为,后者在常规情况下,一个订单都是对应一笔支付单,所以orderID可以作为一个幂等性校验、防止订单重复支付的天然神器。


但这个方案在“如何防止重复提交订单”就不适用了,需要其他的解决方案,请继续看下文。


方案二:预生成全局唯一订单号


(1)后端新增一个接口,用于预生成一个“全局唯一订单号”,如:UUID 或 NanoID。


(2)进入创建订单页面时,前端请求该接口,获取该订单号。


(3)在提交订单时,请求参数里要带上这个预生成的“全局唯一订单号”,利用数据库的唯一索引特性,在插入订单记录时,如果该“全局唯一的订单号”重复,记录会插入失败。


btw:该“全局唯一订单号”不能代替数据库主键,在未分库分表场景下,主键还是用数据库自增ID比较好。



方案二


优点:彻底解决了重复下单的问题;


缺点:方案复杂,前后端都有开发工作量,还要新增接口,新增字段。


另外,网上还有同学说,要单独弄一个生成“全局唯一订单号”的服务,我觉得还是免了吧,这不是更麻烦了吗?


方案三:前端生成全局唯一订单号


这种方案是在借鉴了“方案二”的基础上,做了一些实现逻辑的简化。


(1)用户进入下页面时,前端程序自己生成一个“全局唯一订单号”。


(2)在提交订单时,请求参数里要带上这个预生成的“全局唯一订单号”,利用数据库的唯一索引特性,在插入订单记录时,如果该“全局唯一的订单号”重复,记录会插入失败。



方案三


优点:彻底解决了重复下单的问题,且技术方案做了一定简化;


缺点:前后端仍然都有开发工作量,且需要新增字段;


方案四:从订单业务的本质入手


先跟大家探讨一个概念,什么是订单?


其实,订单就是某个用户用特定的价格购买了某种商品,即:用户和商品的连接。


那么,“如何防止重复提交订单”,其实就是防止在短时间内,用户和商品进行多次连接。弄明白问题本质,接下来我们就着手制定技术方案了。


可以用 ”用户ID + 分隔符 + 商品ID“ 作为唯一标识,让持有相同标识的请求在短时间内不能重复下单,不就可以了吗?而且,Redis不正是做这种解决方案的利器吗?


Redis命令如下:


SET key value NX EX seconds


把”用户ID + 分隔符 + 商品ID“作为Redis key,并把”短时间所对应的秒数“设置为seconds,让它过期自动删除。


这样一来,整体业务步骤如下:


(1)在提交订单时,我们可以把”用户ID + 分隔符 + 商品ID“作为Redis key,并设置过期时间,让它可以到期自动删除。


(2)若Redis命令执行成功,则可以继续走下单的业务逻辑,执行不成功,直接返回给前端”下单失败“就可以了。



方案四


从上图来看,是不是实现方式越来越简单了?


优点:彻底解决了重复下单的问题,且在技术方案上,不需要前端参与,不需要添加接口,不需要添加字段;


缺点:综合比较而言,暂无明显缺点,如果硬要找缺点的话,可能强依赖于Redis勉强可以算上吧;


结语


在真正的生产环境下,我们最终选择了”方案四:从订单业务的本质入手“。


原因很简单,整体改动范围比较小,测试的回归范围也比较可控,且技术方案复杂度最低。


这样做技术选型的话,也比较符合百度一直倡导的”简单可依赖“原则。


作者:库森学长
来源:juejin.cn/post/7273024681631776829
收起阅读 »

前端埋点实现方案

前端埋点的简介埋点是指在软件、网站或移动应用中插入代码,用于收集和跟踪用户行为数据。通过在特定位置插入埋点代码,开发人员可以记录用户在应用中的操作,如页面浏览、按钮点击、表单提交等。这些数据可以用于分析用户行为、优化产品功能、改进用户体验等目的。 埋点通常与...
继续阅读 »

前端埋点的简介

  • 埋点是指在软件、网站或移动应用中插入代码,用于收集和跟踪用户行为数据。

  • 通过在特定位置插入埋点代码,开发人员可以记录用户在应用中的操作,如页面浏览、按钮点击、表单提交等。

  • 这些数据可以用于分析用户行为、优化产品功能、改进用户体验等目的。




埋点通常与数据分析工具结合使用,如Google Analytics、Mixpanel等,以便对数据进行可视化和进一步分析。


前端埋点是指在前端页面中嵌入代码,用于收集和跟踪用户行为数据。


通过埋点可以获取用户在网页或应用中的点击、浏览、交互等动作,用于分析用户行为、优化产品体验和进行数据驱动的决策。


在前端埋点中,常用的方式包括:

  1. 页面加载埋点:用于追踪和监测页面的加载时间、渲染状态等信息。
  2. 点击事件埋点:通过监听用户的点击事件,记录用户点击了哪些元素、触发了什么操作,以及相关的参数信息。
  3. 表单提交埋点:记录用户在表单中输入的内容,并在用户提交表单时将这些数据发送到后台进行保存和分析。
  4. 页面停留时间埋点:用于记录用户在页面停留的时间,以及用户与页面的交互行为,如滚动、鼠标悬停等。
  5. AJAX请求埋点:在前端的AJAX请求中添加额外的参数,用于记录请求的发送和返回状态,以及相应的数据。

埋点数据可以通过后端API或第三方数据分析工具发送到服务器进行处理和存储。


在使用前端埋点时,需要注意保护用户隐私,遵守相关法律法规,并确保数据采集和使用的合法性和合规性。


同时,还需设计良好的数据模型和分析策略,以便从埋点数据中获得有价值的信息。


前端埋点设计


前面说过,前端埋点是一种数据追踪的技术,用于收集和分析用户的行为数据。


前端埋点设计方案有哪些?


下面简单介绍一下:

  1. 事件监听:通过监听用户的点击、滚动、输入等事件,记录用户的操作行为。可以使用JavaScript来实现事件监听,例如使用addEventListener()函数进行事件绑定。

  2. 自定义属性:在HTML元素中添加自定义属性,用于标识不同的元素或事件。 例如,在按钮上添加data-*属性,表示不同的按钮类型或功能。当用户与这些元素进行交互时,可以获取相应的属性值作为事件标识。

  3.  发送请求:当用户触发需要追踪的事件时,可以通过发送异步请求将数据发送到后台服务器。 可以使用XMLHttpRequest、fetch或者第三方的数据上报SDK来发送请求。

  4. 数据格式:确定需要采集的数据格式,包括页面URL、时间戳、用户标识、事件类型、操作元素等信息。 通常使用JSON格式来封装数据,方便后续的数据处理和分析。

  5. 用户标识:对于需要区分用户的情况,可以在用户首次访问时生成一个唯一的用户标识,并将该标识存储在浏览器的cookie中或使用localStorage进行本地存储。

  6. 数据上报:将采集到的数据发送到后台服务器进行存储和处理。可以自建后台系统进行数据接收和分析,也可以使用第三方的数据分析工具,例如百度统计、Google Analytics等。

  7.  隐私保护:在进行数据采集和存储时,需要注意用户隐私保护。

  8.  遵守相关的法律法规,对敏感信息进行脱敏处理或加密存储,并向用户明示数据采集和使用政策。


需要注意的是,在进行埋点时要权衡数据采集的成本与收益,确保收集到的数据具有一定的价值和合法性。


同时,要注意保护用户隐私,遵守相关法律法规,尊重用户的选择和权益。


前端埋点示例


以下是一个完整的前端埋点示例


展示了如何在网站上埋点统计页面浏览、按钮点击和表单提交事件

  • 在HTML中标识需要采集的元素或事件:
<button id="myButton" data-track-id="button1">Click Me</button>

<form id="myForm">
  <input type="text" name="username" placeholder="Username">
  <input type="password" name="password" placeholder="Password">
  <button type="submit">Submit</button>
</form>

在按钮和表单元素上添加了data-track-id自定义属性,用于标识这些元素。

  • 使用JavaScript监听事件并获取事件数据:
// 监听页面加载事件
window.addEventListener("load", function() {
  var pageUrl = window.location.href;
  var timestamp = new Date().getTime();
  var userData = {
    eventType: "pageView",
    pageUrl: pageUrl,
    timestamp: timestamp
    // 其他需要收集的用户数据
  };

  // 封装数据格式并发送请求
  sendData(userData);
});

// 监听按钮点击事件
document.getElementById("myButton").addEventListener("click", function(event) {
  var buttonId = event.target.getAttribute("data-track-id");
  var timestamp = new Date().getTime();
  var userData = {
    eventType: "buttonClick",
    buttonId: buttonId,
    timestamp: timestamp
    // 其他需要收集的用户数据
  };

  // 封装数据格式并发送请求
  sendData(userData);
});

// 监听表单提交事件
document.getElementById("myForm").addEventListener("submit", function(event) {
  event.preventDefault(); // 阻止表单默认提交行为

  var formId = event.target.getAttribute("id");
  var formData = new FormData(event.target);
  var timestamp = new Date().getTime();
  var userData = {
    eventType: "formSubmit",
    formId: formId,
    formData: Object.fromEntries(formData.entries()),
    timestamp: timestamp
    // 其他需要收集的用户数据
  };

  // 封装数据格式并发送请求
  sendData(userData);
});

通过JavaScript代码监听页面加载、按钮点击和表单提交等事件,获取相应的事件数据,包括页面URL、按钮ID、表单ID和表单数据等。

  • 发送数据请求:
function sendData(data) {
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "/track", true);
  xhr.setRequestHeader("Content-Type", "application/json");

  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4 && xhr.status === 200) {
      console.log("Data sent successfully.");
    }
  };

  xhr.send(JSON.stringify(data));
}

使用XMLHttpRequest对象发送POST请求,将封装好的数据作为请求的参数发送到后台服务器的/track接口。

  • 后台数据接收与存储:

后台服务器接收到前端发送的数据请求后,进行处理和存储。


可以使用后端开发语言(如Node.js、Python等)来编写接口逻辑,将数据存储到数据库或其他持久化存储中。


通过监听页面加载、按钮点击和表单提交等事件,并将相关数据发送到后台服务器进行存储和分析。


根据具体项目需求,可以扩展和定制各种不同类型的埋点事件和数据采集。


vue 前端埋点示例


在Vue中实现前端埋点可以通过自定义指令或者混入(mixin)来完成。


下面给出两种常见的Vue前端埋点示例:

  • 自定义指令方式:
// 在 main.js 中注册全局自定义指令 track
import Vue from 'vue';

Vue.directive('track', {
  bind(el, binding, vnode) {
    const { event, data } = binding.value;
    
    el.addEventListener(event, () => {
      // 埋点逻辑,例如发送请求或记录日志
      console.log("埋点事件:" + event);
      console.log("埋点数据:" + JSON.stringify(data));
    });
  }
});

在组件模板中使用自定义指令:

<template>
  <button v-track="{ event: 'click', data: { buttonName: '按钮A' } }">点击按钮A</button>
</template>

  • 1. 混入方式:
// 创建一个名为 trackMixin 的混入对象,并定义需要进行埋点的方法
const trackMixin = {
  methods: {
    trackEvent(event, data) {
      // 埋点逻辑,例如发送请求或记录日志
      console.log("埋点事件:" + event);
      console.log("埋点数据:" + JSON.stringify(data));
    }
  }
};

// 在组件中使用混入
export default {
  mixins: [trackMixin],
  mounted() {
    // 在需要进行埋点的地方调用混入的方法
    this.trackEvent('click', { buttonName: '按钮A' });
  },
  // ...
};

这两种方式都可以实现前端埋点,你可以根据自己的项目需求选择适合的方式。


在实际应用中,你需要根据具体的埋点需求来编写逻辑,例如记录页面浏览、按钮点击、表单提交等事件,以及相应的数据收集和处理操作。


使用自定义指令(Custom Directive)的方式来实现前端埋点


在Vue 3中,你可以使用自定义指令(Custom Directive)的方式来实现前端埋点。


一个简单的Vue 3的前端埋点示例:


  • 创建一个名为analytics.js的文件,用于存放埋点逻辑:
// analytics.js

export default {
  mounted(el, binding) {
    const { eventType, eventData } = binding.value;

    // 发送数据请求
    this.$http.post('/track', {
      eventType,
      eventData,
    })
    .then(() => {
      console.log('Data sent successfully.');
    })
    .catch((error) => {
      console.error('Error sending data:', error);
    });
  },
};

  • 在Vue 3应用的入口文件中添加全局配置:
import { createApp } from 'vue';
import App from './App.vue';
import axios from 'axios';

const app = createApp(App);

// 设置HTTP库
app.config.globalProperties.$http = axios;

// 注册全局自定义指令
app.directive('analytics', analyticsDirective);

app.mount('#app');

  • 在组件中使用自定义指令,并传递相应的事件类型和数据:
<template>
  <button v-analytics="{ eventType: 'buttonClick', eventData: { buttonId: 'myButton' } }">Click Me</button>
</template>

在示例中,我们定义了一个全局的自定义指令v-analytics,它接受一个对象作为参数,对象包含了事件类型(eventType)和事件数据(eventData)。当元素被插入到DOM中时,自定义指令的mounted钩子函数会被调用,然后发送数据请求到后台服务器。


注意,在示例中使用了axios作为HTTP库发送数据请求,你需要确保项目中已安装了axios,并根据实际情况修改请求的URL和其他配置。


通过以上设置,你可以在Vue 3应用中使用自定义指令来实现前端埋点,采集并发送相应的事件数据到后台服务器进行存储和分析。请根据具体项目需求扩展和定制埋点事件和数据采集。


使用Composition API的方式来实现前端埋点


以下是一个Vue 3的前端埋点示例,使用Composition API来实现:

  • 创建一个名为analytics.js的文件,用于存放埋点逻辑:
// analytics.js

import { ref, onMounted } from 'vue';

export function useAnalytics() {
  const trackEvent = (eventType, eventData) => {
    // 发送数据请求
    // 模拟请求示例,请根据实际情况修改具体逻辑
    console.log(`Sending ${eventType} event with data:`, eventData);
  };

  onMounted(() => {
    // 页面加载事件
    trackEvent('pageView', {
      pageUrl: window.location.href,
    });
  });

  return {
    trackEvent,
  };
}

  • 在需要进行埋点的组件中引入useAnalytics函数并使用:
import { useAnalytics } from './analytics.js';

export default {
  name: 'MyComponent',
  setup() {
    const { trackEvent } = useAnalytics();

    // 按钮点击事件
    const handleClick = () => {
      trackEvent('buttonClick', {
        buttonId: 'myButton',
      });
    };

    return {
      handleClick,
    };
  },
};

  • 在模板中使用按钮并绑定相应的点击事件:
<template>
  <button id="myButton" @click="handleClick">Click Me</button>
</template>

在示例中,我们将埋点逻辑封装在了analytics.js文件中的useAnalytics函数中。在组件中使用setup函数来引入useAnalytics函数,并获取到trackEvent方法进行埋点操作。在模板中,我们将handleClick方法绑定到按钮的点击事件上。


当页面加载时,会自动发送一个pageView事件的请求。当按钮被点击时,会发送一个buttonClick事件的请求。


注意,在示例中,我们只是模拟了数据请求操作,请根据实际情况修改具体的发送数据请求的逻辑。


通过以上设置,你可以在Vue 3应用中使用Composition API来实现前端埋点,采集并发送相应的事件数据。请根据具体项目需求扩展和定制埋点事件和数据采集。


react 前端埋点示例


使用自定义 Hook 实现


当然!以下是一个 React 的前端埋点示例,


使用自定义 Hook 实现:

  • 创建一个名为 useAnalytics.js 的文件,用于存放埋点逻辑:
// useAnalytics.js

import { useEffect } from 'react';

export function useAnalytics() {
  const trackEvent = (eventType, eventData) => {
    // 发送数据请求
    // 模拟请求示例,请根据实际情况修改具体逻辑
    console.log(`Sending ${eventType} event with data:`, eventData);
  };

  useEffect(() => {
    // 页面加载事件
    trackEvent('pageView', {
      pageUrl: window.location.href,
    });
  }, []);

  return {
    trackEvent,
  };
}

  • 在需要进行埋点的组件中引入 useAnalytics 自定义 Hook 并使用:
import { useAnalytics } from './useAnalytics';

function MyComponent() {
  const { trackEvent } = useAnalytics();

  // 按钮点击事件
  const handleClick = () => {
    trackEvent('buttonClick', {
      buttonId: 'myButton',
    });
  };

  return (
    <button id="myButton" onClick={handleClick}>Click Me</button>
  );
}

export default MyComponent;

在示例中,我们将埋点逻辑封装在了 useAnalytics.js 文件中的 useAnalytics 自定义 Hook 中。在组件中使用该自定义 Hook 来获取 trackEvent 方法以进行埋点操作。在模板中,我们将 handleClick 方法绑定到按钮的点击事件上。


当页面加载时,会自动发送一个 pageView 事件的请求。当按钮被点击时,会发送一个 buttonClick 事件的请求。


请注意,在示例中,我们只是模拟了数据请求操作,请根据实际情况修改具体的发送数据请求的逻辑。


通过以上设置,你可以在 React 应用中使用自定义 Hook 来实现前端埋点,采集并发送相应的事件数据。根据具体项目需求,你可以扩展和定制埋点事件和数据采集逻辑。


使用高阶组件(Higher-Order Component)实现


当然!以下是一个 React 的前端埋点示例,


使用高阶组件(Higher-Order Component)实现:

  • 创建一个名为 withAnalytics.js 的高阶组件文件,用于封装埋点逻辑:
// withAnalytics.js

import React, { useEffect } from 'react';

export function withAnalytics(WrappedComponent) {
  return function WithAnalytics(props) {
    const trackEvent = (eventType, eventData) => {
      // 发送数据请求
      // 模拟请求示例,请根据实际情况修改具体逻辑
      console.log(`Sending ${eventType} event with data:`, eventData);
    };

    useEffect(() => {
      // 页面加载事件
      trackEvent('pageView', {
        pageUrl: window.location.href,
      });
    }, []);

    return <WrappedComponent trackEvent={trackEvent} {...props} />;
  };
}

  • 在需要进行埋点的组件中引入 withAnalytics 高阶组件并使用:
import React from 'react';
import { withAnalytics } from './withAnalytics';

function MyComponent({ trackEvent }) {
  // 按钮点击事件
  const handleClick = () => {
    trackEvent('buttonClick', {
      buttonId: 'myButton',
    });
  };

  return (
    <button id="myButton" onClick={handleClick}>Click Me</button>
  );
}

export default withAnalytics(MyComponent);

在示例中,我们创建了一个名为 withAnalytics 的高阶组件,它接受一个被包裹的组件,并通过属性传递 trackEvent 方法。在高阶组件内部,我们在 useEffect 钩子中处理页面加载事件的埋点逻辑,并将 trackEvent 方法传递给被包裹组件。被包裹的组件可以通过属性获取到 trackEvent 方法,并进行相应的埋点操作。


在模板中,我们将 handleClick 方法绑定到按钮的点击事件上。


当页面加载时,会自动发送一个 pageView 事件的请求。当按钮被点击时,会发送一个 buttonClick 事件的请求。


请注意,在示例中,我们只是模拟了数据请求操作,请根据实际情况修改具体的发送数据请求的逻辑。


通过以上设置,你可以在 React 应用中使用高阶组件来实现前端埋点,采集并发送相应的事件数据。


当然根据具体项目需求,你还可以扩展和定制埋点事件和数据采集逻辑。


作者:朦胧之
链接:https://juejin.cn/post/7274084216286183460
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

如何突破技术瓶颈(适合P6以下)

前言 最近在更新react组件库源码的文章,其实vue和其他框架都一样,就是我发现自己在一年前直接看这些源码(不调试),还是有点吃力的,然后就放弃了。 可最近不知道为啥,看这些源码对我来说没啥太大难度,直接干就完事了,不需要啥调试。自己好好回想了一下,为什么会...
继续阅读 »

前言


最近在更新react组件库源码的文章,其实vue和其他框架都一样,就是我发现自己在一年前直接看这些源码(不调试),还是有点吃力的,然后就放弃了。


可最近不知道为啥,看这些源码对我来说没啥太大难度,直接干就完事了,不需要啥调试。自己好好回想了一下,为什么会有这样的变化?也算帮助一些想突破自己技术瓶颈的同学。


有新人在下面留言说看到很焦虑,刚进前端领域的同学,你们首要任务是能完成业务开发,此时业务开发带给你的提升是最明显的,文章更多的是帮助业务api用熟之后的想有突破的同学,不用焦虑,哈哈。而且话说回来了,我在平时工作中看到不想突破的人基本占百分90%,无论大小厂,所以不突破也没啥,大部分人只是仅仅当一个普通工作而已。


结论


首先我得出结论是:

  • 最开始不要自己去读源码,看别人的文章和视频即可,目的是先接触比自己能力层次高的代码,为超越现有的能力铺路(后面详细谈怎么做)
  • 平时注意积累一些手写题的思路,网上面经很多,主要不是写出来,是理解原理,理解大于一切,不理解的东西终究会忘记,我们要积累的是能力,能力是第一!(后面详细谈),设计模式里的发布订阅者模式必须要理解!这是写很多库常见的技巧。
  • 最后开始独立去看一些小的代码库,比如腾讯,阿里,字节的组件库,这些库大部分组件难度低。

去哪里看视频和文章学源码


视频


最简易的就是跟着视频学,因为视频会把代码敲一遍,给你思考的时间,讲解也是最细的,很适合刚开始想造轮子的同学了解一些有难度的源码。


举个例子:


我当时看了koa的源码,了解了koa中间件的原理,我自己造了一个自动化发布脚本就利用了这个原理,redux中间件也是类似的原理,在函数式编程领域叫做compose函数,koa是异步compose,redux是同步compose,


简单描述下什么是compose函数


我把大象装进冰箱是不是要
1、打开冰箱门
2、装进去大象
3、关冰箱门


那么很多同学就会写一个函数

function 装大象(){
// 打开冰箱
// 装大象
// 关闭冰箱门
}

compose函数会把这个过程拆开,并且抽象化

// 把装大象抽象为装东西函数
function 装东西();
function 打开冰箱();
function 关闭冰箱();

compose(打开冰箱函数, 装东西函数,关闭冰箱函数)

此时compose把上面三个函数抽象为一个打开冰箱往里面装东西的函数,我们只需要把参数大象穿进去就抽象了整个过程

compose(打开冰箱函数, 装东西函数,关闭冰箱函数)(大象)

具体内容我还写过一篇文章,有兴趣的同学可以去看看:


终极compose函数封装方案!


这个大家应该有自己的去处,我自己的话很简单,视频一般去b站,就是bilibili,有些同学以为这是一个二次元网站是吧,其实里面免费的学习资料一抓一大把呢,啥都有。


比如说我在b站看了很多linux入门教学视频,还有一个培训公开课,讲的都是源码,什么手写react hook,手写webpack,手写xxx,那个时候说实话,听了视频也不是很理解,但是我还是挺喜欢前端的,没咋理解就继续听。


记住,我们需要短时间内提升能力,所以视频算是其中最快的了,其他方法不可能有这个来的快,并且没理解就算了,能理解多少是多少。


学习是一个螺旋上升的过程,不是一下子就全懂或者全不懂的,都是每次比上一次更懂一点。除非你是天才,急不来的。


视频搜索第二大去处就是论坛,一些论坛有各种各样的培训视频,这种论坛太多了,你谷歌或者百度一抓一大把。


对了,谷歌是爸爸,你懂我意思,不要吝啬小钱。在搜索学习资料面前,百度就是个弟弟。


文章


文章一定记住,在精不在多。


切记,每个人都处在不同的学习阶段,不要盲目追求所谓的大神文章,不一定适合你,比如说有些人刚接触前端,你去看有些有深度的文章对你没啥好处,浪费时间,因为你理解不了,理解不了的知识相当于没学,过两天就忘了。


文章选择范围,比如掘金,知乎还有前端公众号,基本上就差不多了,选一两个你觉得你这个阶段能吸收的,好好精读,坚持个一年你会发现不一样的。


额外的知识储备


前端3年前主流的前端书我都读过,什么红宝书,权威指南都读了好几遍了。


但有一本从菜鸟到高级-资深前端很推荐的一本是:JavaScript设计模式与开发实践(图灵出品)(腾讯的一位大哥写的,不是百度的那位,这两本书我都看过)


里面的知识点很干很干,里面有非常多的技巧,比如说你的同事写了一个函数,你不想破坏函数,有什么办法拓展它(其实我觉得我想的这些题就比前端八股文好玩多了,是开放性的)

  • 技巧很多,比如面向切面编程,加个before或者after函数包装一下
  • 比如责任链模式
  • 比如刚才的compose函数
  • 比如装饰器模式

确立自己的发展方向


大家其实最后都要面对一个很现实的问题,就是35以后怎么办,我个人觉得你没有对标阿里P7的能力,落地到中小公司都难。


所以我们看源码,看啥都是为了提升能力,延长职业寿命。


那么如何在短时间内有效的提升,你就需要注意不能各种方向胡乱探索,前端有小游戏方向,数据可视化方向,B端后台系统方向,音视频方向等等


我是做b端,那b端整个链路我就需要打通,组件库是我这个方向,所以我探索这里,还有node端也是,写小工具是必须的,但是你们说什么deno,其他的技术,我根本不在乎,没时间浪费在这些地方,当然除了有些业务上需要,比如之前公司有个ai标注需求,用canvas写了一个类似画板的工具,也算开拓了知识点,但这也不是我重点发展的方向,不深入。


我做组件库是为了后面的低代码,低代码平台的整体设计思路我已经想好了,整体偏向国外开源的appsmith的那种方式,然后打通组件间通信的功能,我认为是能胜任稍微复杂的b端业务场景的,而且可以走很多垂直领域,比如网站建站,微信文章编辑器这种。所以我才开始研究组件库的,因为低代码大多数复杂功能都在组件上。


工作上勇于走出舒适圈


为什么这个跟看源码相关呢,如果你做过比较复杂的项目,你会发现很多现成的第三方库满足不了。比如说我自己遇到过的大型sass项目,ant design就满足不了,所以你才发现,源码看得少加上业务急,代码就烂,时间上就留不出自己偷偷学习的时间,如果你想长期从事软件开发,没有成长是一件很危险的事(钱多当我没说,哈哈),因为无论如何,有本事,总没错的。


当你的业务难度上去的时候,会逼着你去提升能力,所以你如果想前端走的更远,建议不要在自己的舒适区太久,业务上选择一家比较难的公司,后面再跳槽就是沉淀这段时间的知识点了,当你能够有自信说,我现在带团队,从0到1再遇到那么难的业务时,能从容应对,恭喜你,你可以去面下阿里p7,不是为了这个工作啊,可以检验下是不是达到这个职位的标准了,我就喜欢偶尔面一下,也不是换工作,就是看看自己进步没


作者:孟祥_成都
链接:https://juejin.cn/post/7168671474234949662
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

读完React新文档后的8条收获

不久前,React官网进行了重构。新官网将hook作为介绍的重头戏,重新通读了一遍react文档,有了一些自己的感悟。在此分享出来与各位共勉。 1. 换个角度认识Props与State Props与State是React中两个略有相似的概念。在一个React组...
继续阅读 »

不久前,React官网进行了重构。新官网将hook作为介绍的重头戏,重新通读了一遍react文档,有了一些自己的感悟。在此分享出来与各位共勉。


1. 换个角度认识Props与State


PropsState是React中两个略有相似的概念。在一个React组件中,它们作为数据来源可能同时存在,但它们之间有着巨大不同:

  1. Props更像函数中的参数。它主要用于组件之间的信息传递,它可以是任意类型的数据:数字、文本、函数、甚至于组件等等。
  2. State更像组件中的内存。它可以保存组件内部的状态,组件通过跟踪这些状态,从而渲染更新。
import React, { useState } from 'react';

// 父组件
const ParentComponent = () => {
const [count, setCount] = useState(0); // 使用state来追踪count的值

return (
<div>
<ChildComponent age={25} />
<p>Count: {count}</p>
</div>
);
};

// 子组件
const ChildComponent = (props) => {
const { age } = props; // 使用props来获取父组件传递的数据

return (
<div>
<p>Age: {age}</p>
</div>
);
};

2. 不要嵌套定义组件


在一个组件中直接定义其他组件,可以省去很多传递Props的工夫,看上去很好。但我们不应该嵌套定义组件,原因在于**嵌套定义组件会导致渲染速度变慢,也更容易出现BUG**。
我们在嵌套定义组件的情况下,当父组件更新时,内部嵌套的子组件也会重新生成并渲染,子组件内部的state也会丢失。针对这种情况,我们可以通过两种方式来解决:

  1. 为子组件包上useMemo,避免不必要的更新;
  2. 不再嵌套定义,将子组件提至父组件外,通过Props传参。

这里更推荐第二种方法,因为这样代码更少,结构也更清晰,组件迁移维护都会更方便一些。

//🔴 Bad Case
export default function Gallery() {
function Profile() {
// ...
}
// ...
}
//✅ Good Case
function Profile() {
// ...
}

export default function Gallery() {
// ...
}

3. 尽量不要使用匿名函数组件


因为类似export default () => {}的匿名函数组件书写虽然方便,但会让debug变得更困难
如下是两种不同类型组件出错时的控制台的表现:

  1. 具名组件出错时的提示,可直接的指出错的函数组件名称: 


  1. 匿名函数出错时的提示,无法直观确定页面内哪个组件出了错: 



4. 使用逻辑运算符&&编写JSX时,左侧最好不要是数字


运算符&&在JSX中的表现与JS略有不同:

  • 在JS中,只有在&&左侧的值0nullundefinedfalse''等假值时,才会返回右侧值;
  • 在JSX中,React对于falsenullundefined并不会做渲染处理,所以进行&&运算后,如果左侧值为假被返回后,会出现与直觉不同的渲染结果:可能会碰到页面上莫名奇妙出现了一个0的问题。为了避免出现这种情况,可以在书写JSX时,为左侧的值加上!!来进行强制类型转换。
const flag = 0
//🔴 Bad Case
{
flag && <div>123</div>
}
//✅ Good Case 1
{
!!flag && <div>123</div>
}
//✅ Good Case 2
{
flag > 0 && <div>123</div>
}

关于JSX对各种常见假值的渲染,这里进行了总结:

  1. nullundefinedfalse:这些值在JSX中会被视为空,不会渲染任何内容。它们会被忽略,并且不会生成对应的DOM元素。
  2. 0NaN:这些值会在页面上渲染为字符串"0"、"NaN"。
  3. ''[]{}:空字符串会被渲染为什么都没有,不会显示任何内容。


注:这里感谢@小明家的bin的评论提醒,他的见解对我起到了很大的启发作用。



5.全写的 Fragment标签上可以添加属性key


在map循环添加组件时,需要为组件添加key来优化性能。如果待循环的组件有多个时,可以在外层包裹<Fragment>...</Fragment>标签,在其上添加key添加在<Fragment>...</Fragment>来避免创建额外的组件。

const list = [1,2,3]
//🔴 Bad Case
//不能添加key
{
list.map(v=><> <div>1-1</div> <div>1-2</div> </>)
}
//🔴 Bad Case
//创建了额外的div节点
{
list.map(v=><div key={v}> <div>1-1</div> <div>1-2</div> <div/>)
}
//✅ Good Case
{
list.map(v=><Fragment key={v}> <div>1-1</div> <div>1-2</div> </Fragment>)
}



注意简写的Fragment标签<>...</>上不支持添加key



6. 可以使用updater function,来在下一次渲染之前多次更新同一个state


React中处理状态更新时采用了批处理机制,在下一次渲染之前多次更新同一个state,可能不会达到我们想要的效果。如下是一个简单例子:

// 按照直觉一次点击后button中的文字应展示为3,但实际是1
function Demo(){
const [a,setA] = useState(0)

function handler(){
setA(a + 1);
setA(a + 1);
setA(a + 1);
}

return <button onclick={handler}>{a}</button>
}

在某些极端场景下,我们可能希望state的值能够及时发生变化,这时便可以采用setA(n => n + 1)(这种形式被称为updater function)来进行更新:

// 一次点击后a的值会被更新为3
function Demo(){
const [a,setA] = useState(0)

function handler(){
setA(n => n + 1);
setA(n => n + 1);
setA(n => n + 1);
}

return <button onclick={handler}>{a}</button>
}

7. 管理状态的一些原则


更加结构化的状态有助于提高我们组件的健壮性,以下是一些管理组件内状态时可以参考的原则:

  1. 精简相关状态:如果在更新某个状态时总是需要同步更新其他状态变量,可以考虑将它们合并为一个状态变量。
  2. 避免矛盾状态:避免出现两个或多个状态的值互相矛盾的情况。
  3. 避免冗余状态:一个状态可以由其余状态计算而来时,其不应单独作为一个状态来进行管理。
  4. 避免状态重复:当相同的数据在多个状态变量之间或嵌套对象中重复时,很难使它们保持同步。尽可能减少重复。
  5. 避免深度嵌套状态:嵌套层级过深的状态更新时相对麻烦许多,尽量保持状态扁平化。

8. 使用useSyncExternalStore订阅外部状态


useSyncExternalStore是一个React18中新提供的一个Hook,可以用于订阅外部状态。
它的使用方式如下:

import { useSyncExternalStore } from 'react';

function MyComponent() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
// ...
}

useSyncExternalStore接受三个参数:

  • subscribe:一个订阅函数,用于订阅存储,并返回一个取消订阅的函数。
  • getSnapshot:一个从存储中获取数据快照的函数。在存储未发生变化时,重复调用getSnapshot应该返回相同的值。当存储发生变化且返回值不同时,React会重新渲染组件。
  • getServerSnapshot(可选):一个在服务器端渲染时获取存储初始快照的函数。它仅在服务器端渲染和在客户端进行服务器呈现内容的hydration过程中使用。如果省略该参数,在服务器端渲染组件时会抛出错误。

举一个具体的例子,我们可能需要获取浏览器的联网状态,并期望在浏览器网络状态发生变化时做一些处理。使用useSyncExternalStore可以很大程度上简化我们的代码,同时使逻辑更加清晰:

//🔴 Bad Case
function useOnlineStatus() {
// Not ideal: Manual store subscription in an Effect
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}

updateState();

window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}

// ✅ GoodCase
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true // How to get the value on the server
);
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

结语


文章的最后,再来一次无废话总结:

  1. 更清晰地认识了Props与State之间的区别。Props更像是函数的参数,用于组件之间的信息传递;而State更像是组件内部的内存,用于保存组件的状态并进行渲染更新。
  2. 不推荐在一个组件内部嵌套定义其他组件,因为这样会导致渲染速度变慢并容易产生BUG。推荐将子组件提到父组件外部并通过Props传递数据。
  3. 尽量避免使用匿名函数组件,因为在出错时会增加调试的难度。具名组件的出错提示更加直观和准确。
  4. 在使用逻辑运算符&&编写JSX时,左侧最好不要是数字。在JSX中,0会被当作有效的值,而不是假值,为了避免出现问题,可以在左侧的值加上!!进行强制类型转换。
  5. 当在使用全写的Fragment标签时,可以给Fragment标签添加属性key,以优化性能和避免创建额外的组件。
  6. 使用updater function的方式进行状态更新,可以确保在下一次渲染之前多次更新同一个state。这样可以避免批处理机制带来的问题。
  7. 在管理组件内状态时,可以遵循一些原则,如精简相关状态、避免矛盾状态、避免冗余状态、避免状态重复等,以提高组件的健壮性和可维护性。
  8. 使用useSyncExternalStore可以订阅外部状态,它是React 18中新增的Hook。通过订阅函数、获取数据快照的函数以及获取服务器初始快照的函数,我们可以简化订阅外部状态的代码逻辑。

通过对React文档的深入学习和实践,我对React的理解更加深入了解,希望这些收获也能对大家在学习和使用React时有所帮助。


作者:星始流年
链接:https://juejin.cn/post/7233324859932442680
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

不要因bundle size太大责备开发者

前言 大家好我是飞叶,一个前端UP主,最近外网有个新的SSR框架Qwik比较火,GitHub上它的仓库star数以每天100的速度在增长,今天就通过翻译一篇文章来引入QWIK。 当然如果你不想读这些,我也录了一个视频讲这篇文章。讲解视频在这里 原文链接 ht...
继续阅读 »

前言


大家好我是飞叶,一个前端UP主,最近外网有个新的SSR框架Qwik比较火,GitHub上它的仓库star数以每天100的速度在增长,今天就通过翻译一篇文章来引入QWIK。


当然如果你不想读这些,我也录了一个视频讲这篇文章。讲解视频在这里



原文链接 http://www.builder.io/blog/dont-b…



不要因bundle size太大责备开发者


让我们谈谈我们构建 Web 应用程序所必须使用的工具,以及这些工具如何欺骗我们。


开发者们共同的故事


你要创建一个新项目,此时,你信心满满这个新站点会很快很流畅。
在一开始,事情看起来确实如此,但是很快你的应用就变大了变复杂了,应用的开启性能变慢了。
在不知不觉中,您手头上有一个巨大的应用程序,而您却无能为力地修复它。你哪里做错了?


我们用的每个工具/框架都承诺提供更好、更快的结果,
但我们通过访问整个互联网里的应用就知道,结果绝不是一个更好、更快的站点。
谁应该为此负责?开发者吗?


作为开发者,你是否有被告知:"就是你们搞砸了,你们偷工减料,才导致了一个性能差的站点。"


这不是你的错


如果一些网站速度较慢,而另一些网站速度较快,那么当然,责备开发人员可能是有道理的。
但真实情况是:所有网站都很慢!
当没有人成功时,你怎么能责怪开发者呢?问题是系统性的。也许这不是开发者的错。


这是一个关于我们如何构建应用程序、我们使用的工具、工具做出的承诺以及我们最终遇到的缓慢站点的故事。


只有一个结论。这些工具都过度承诺了,这是整个行业的系统性问题。这不仅仅是几个坏苹果,而是整个万维网。


代码太多了


我们都知道问题是什么:代码太多!我们非常擅长创建代码,浏览器无法跟上我们的脚步。
每个人都告诉你你的网站有太多的 JavaScript,但没有人知道如何缩小它。


这就像一个 YouTube 健身频道告诉你减肥所需要做的就是少摄入卡路里。
简单的建议,但成功率令人沮丧。
原因是该建议忽略了食欲。
当你又饿又虚弱并且只想到食物时,又有几个人能做到 减少卡路里摄入的意愿呢?
所以也许减肥成功的秘诀可能不是减少卡路里,而是如何控制你的食欲。


这个例子很类似于 JavaScript 膨胀的情况。
我们知道我们需要更少的 JavaScript,
但是我们有太多需求,除了代码要写,还有太多工具和轮子要用,(才能满足需求)
但是所有这些 代码 和工具 都会源源不断地使我们的应用越来越大。




打包的演变历史


让我们先看看我们是如何陷入这种境地的,然后再讨论前进的道路。


第 0 代:串联


在 ECMAScript 模块之前,什么都没有,只是文件。
打包过程很简单。这些文件被连接在一起并包装在 IIFE 中。


好处是很难向您的应用程序添加更多代码,因此bundle size保持较小。


第 1 代:打包器


ECMAScript 模块来了。
打包器也出现了:WebPack、Rollup 等。


然而,npm install 一个依赖并把它打包进去有点太容易了。很快,bundle size就成了一个问题。


庆幸的是,这些打包器知道如何进行tree shaking和死代码消除。这些功能确保只有用到的的代码才被打包。


第 2 代:延迟加载


意识到bundle size过大的问题, 打包器开始提供延迟加载。
延迟加载很棒,因为它允许将代码分解成许多chunks并根据需要交付给浏览器。
这很棒,因为它允许从最需要的部分开始 分批交付应用程序。


问题在于,在实践中,我们是使用框架来构建应用程序的,而框架对打包程序如何将我们的代码分解为延迟加载的块有很大影响。
问题在于延迟加载块需要引入异步API调用。
如果框架需要对您的代码进行同步引用,则打包器不能引入延迟加载的块。


所以我们需要明白,虽然打包器声称他们可以延迟加载代码,而且这也是真的,
但想做到延迟加载有个前提条件,即我们使用的框架得让开发者使用promise(来懒加载chunk),否则您可能没有太多选择。


第 3 代:延迟加载不在渲染树中的组件


框架迅速争先恐后地利用打包器的延迟加载功能,如今几乎所有人都知道如何进行延迟加载。
但是有一个很大的警告!框架只能延迟加载不在当前渲染树中的组件。




什么是渲染树?它是构成当前页面的一组组件。
应用程序通常具有比当前页面上更多的组件。
通常,渲染树包含视图(这是您当前在浏览器视口中看到的内容)内组件。
和一部分视图之外的组件。


假设一个组件在渲染树中。在这种情况下,框架必须下载组件,因为框架需要重建组件的渲染树,(这是hydration的一部分工作)。
框架只能延迟加载当前不在渲染树中的组件。


另一点是框架可以延迟加载组件,但总是包含行为。
因为组件包含了行为,这个懒加载的单位就太大了。如果可以延迟加载的单位更小会更好。
渲染组件不应要求下载组件的事件处理程序。
框架应该只在用户交互时才下载事件处理程序,而不是作为组件渲染方法的一部分。根
据您正在构建的应用程序的类型,事件处理程序可能代表您的大部分代码。
所以耦合组件的渲染和行为的下载是次优的。


问题的核心


仅在需要重新渲染组件时才延迟加载组件渲染函数,并且仅在用户与事件处理程序交互时才延迟加载事件处理程序。
这样才是最好的!
默认应该是所有内容都是延迟加载的。


但这种方法存在一个大问题。问题是框架需要协调其内部状态与 DOM。
这意味着至少需要一次hydration,来进行完整渲染以重建框架的内部状态。
在第一次渲染之后,框架可以对其更新进行更准确的把控,但问题已经产生了,因为代码已经下载了。所以我们有两个问题:

  • 框架需要下载并执行组件以在启动时重建渲染树。(请参阅hydration 是纯粹的开销)这会强制下载和执行渲染树中的所有组件。
  • 事件处理程序随组件一起提供,即使在渲染时不需要它们。包含事件处理程序会强制下载不必要的代码。

因此,当今框架的现状是,必须急切地下载和执行 SSR/SSG 渲染树中的每个组件(及其处理程序)。
使用当今的框架进行延迟加载有点说谎,因为您并不能在初始页面呈现时进行延迟加载。


值得指出的是,即使开发人员将延迟加载边界引入 SSR/SSG 初始页面,也无济于事。
框架仍需下载并执行 SSR/SSG 响应中的所有组件;因此,只要组件在渲染树中,框架就必须急切地加载开发人员试图延迟加载的组件。


渲染树中组件的急切下载是问题的核心,开发人员对此无能为力。
尽管如此,这并不能阻止开发人员因网站运行缓慢而受到指责。


下一代:细粒度的延迟加载


那么,我们该何去何从?显而易见的答案是我们需要更细粒度。该解决方案既明显又难以实施。我们需要:

  • 更改框架,这样它们就不会在hydration阶段急切地加载渲染树。
  • 允许组件渲染函数 独立于组件事件处理程序 单独下载。

如果您的框架可以完成上述两个部分,那么用户将看到巨大的好处。
应用程序的启动要求很少,因为启动时不需要进行渲染(内容已经在 SSR/SSG 处渲染)。
下载的代码更少:当框架确定需要重新渲染特定组件时,框架可以通过下载渲染函数来实现,而无需下载所有事件处理程序。


细粒度的延迟加载将是网站启动性能的巨大胜利。
它要快得多,因为下载的代码量将与用户交互性成正比,而不是与初始渲染树的复杂性成正比。
您的网站会变得更快,不是因为我们更擅长使代码更小,而是因为我们更擅长只下载我们需要的东西,而不是预先下载所有东西。




入口点 entry point


拥有一个可以进行细粒度延迟加载的框架是不够的。
因为,要利用细粒度的延迟加载,您必须首先拥有要延迟加载的bundles。


为了让打包器创建延迟加载的chunk,打包器需要每个块的入口点。
如果您的应用程序只有单个入口点,则打包器无法创建多个chunks。
如果您的应用程序只有单个入口点,即使你的框架可以进行细粒度的延迟加载,它也没有什么可以延迟加载的。


现在创建入口点很麻烦,因为它需要开发人员编写额外的代码。
在开发应用程序时,我们真的只能考虑一件事,那就是写功能。
让开发人员同时考虑他们正在构建的功能和延迟加载对开发人员来说是不公平的。
所以在实践中,为打包器创建入口点很麻烦。


所需要的是一个无需开发人员考虑就可以创建入口点的框架。
为打包程序创建入口点是框架的责任,而不是开发人员的责任。
开发人员的职责是构建功能。
该框架的职责是考虑应该如何完成该功能的底层实现。
如果框架不这样做,那么它就不能完全满足开发人员的需求。


担心切入点太多?


目标应该是创建尽可能多的入口点。
但是,有些人可能会问,这是不是就会导致下载很多小块而不是几个大块吗?答案是响亮的“不”。


如果没有入口点,打包器就无法创建chunk。
但是打包器可以将多个入口点放入一个chunk中。
您拥有的入口点越多,您以最佳方式组装bundle的自由度就越大。
入口点给了你优化bundle的自由。所以它们越多越好。


未来的框架


下一代框架将需要解决这些问题:

  • 拥有人们喜欢的开发体验DX。
  • 对代码进行细粒度的延迟加载。
  • 自动生成大量入口点以支持细粒度的延迟加载。

开发人员将像现在一样构建他们的网站,但这些网站不会在应用程序启动时用下载和执行一个很大的bundle来压倒浏览器。


Qwik是一个在设计时考虑到这些原则的框架。Qwik细粒度延迟加载是针对每个事件处理程序、渲染函数和effect的。


结论


我们的网站越来越大,看不到尽头。
它们之所以大,是因为这些网站今天比以前做得更多——更多的功能、动画等。并且这种趋势将继续下去。


上述问题的解决方案是对代码进行细粒度的延迟加载,这样浏览器就不会在初始页面加载时不堪重负。


我们的打包工具支持细粒度的延迟加载,但我们的框架不支持。
框架hydration强制渲染树中的所有组件在hydration时加载。(目前的SSR框架唯一的延迟加载是 当前不在渲染树中的组件。)
即使事件处理程序可能是代码的大部分,并且hydration并不需要事件处理器代码,现在的SSR框架还是随组件的下载一并下载了事件处理程序.


因为打包器可以细粒度的延迟加载,但我们的框架不能,我们无法识别其中的微妙之处。
导致的结果就是我们将网站启动缓慢归咎于开发人员,
因为我们错误地认为他们本可以采取一些措施来防止这种情况发生,尽管现实是他们在这件事上几乎没有发言权。


我们需要将细粒度延迟加载设计为框架核心功能的新型框架(例如Qwik )。
我们不能指望开发者承担这个责任;他们已经被各种功能淹没了。
框架需要考虑延迟加载运行时以及创建入口点,以便打包程序可以创建块以进行延迟加载。
下一代框架带来的好处将超过迁移到它们所花费的成本。


作者:飞叶_前端
链接:https://juejin.cn/post/7154948728871190535
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

被裁员半年了,谈谈感想

后端开发,22年9月,跳槽到某新能源生态企业,23年3月中旬的某个周一下午,被HR通知到会议室做个沟通,两周前收到转正答辩PPT模板让我填写,原本以为是做转正答辩的相关沟通,结果是沟通解除劳动合同,赔偿N+1,第二天就是lastday。 进入公司后经历了几次组...
继续阅读 »

后端开发,22年9月,跳槽到某新能源生态企业,23年3月中旬的某个周一下午,被HR通知到会议室做个沟通,两周前收到转正答辩PPT模板让我填写,原本以为是做转正答辩的相关沟通,结果是沟通解除劳动合同,赔偿N+1,第二天就是lastday。

进入公司后经历了几次组织架构调整,也不断变化着业务形态,但本着拥抱变化的心态,想着会越来越好,又想着自己技术在同事间也不会排到后面,所以对于这个突发状况毫无准备。


心路历程


首月


刚刚经历裁员,下个月会有工资、奖金和赔偿金入账,赔偿金不扣税,同时对于市场环境没有了解,比较乐观。首月的想法就是写简历,并开始投递,先投不想去的公司找面试经验;找学习资料、刷题;期望薪资是不需要涨薪,大概平薪就行。

首月面了三家公司,发现了自己的诸多漏洞,项目比较垂类,讲解过程混乱;基础知识复习不足,很多新出来的延展概念了解不够。


第二个月


上个月期盼的奖金到账了,有些庆幸,又有些失落。庆幸的是收到一笔不菲的补偿金,失落的是下月开始就没有收入了。

发现面试机会变少了,整月才面了三四家,这个月发现的问题,更多的是从架构角度来的,诸如幂等、一致性hash等场景,个人了解的相对简单了。


第三个月


广深的工作机会实在是少,开始同时投递其他城市的岗位试水。月初一家公司现场面了4轮都很顺利,第二天最后一轮CTO面,被嘲讽之前业务简单,比较受打击。月底面其他城市的岗位,一面过后第二天晚上10点又被拉上线做一面的补充面。

开始焦虑了,一想到还没找到工作,补偿金估计一两个月也会花完,可能要动用积蓄了,心跳就加速,越想越加速。努力想让自己变得不去想,只去想没有掌握的知识点,算是熬过了这个月。


第四个月


这个月,感觉蛮顺利,月初面一家大厂,技术面、主管面、HR面、提交资料都很顺利,感觉稳了,每天都看看公众号的面试状态,希望能快点沟通offer;月中也走完了一家中厂的4轮面试流程;月底又走完了另一家新能源车企的面试流程。

整个月过完,自己感觉飘了,感觉同时手握3个offer机会,晚几天随便一家给offer call就去了。个人心态一下子就变了,月内简历几乎没怎么投了,看知识点好像也没那么认真了。


第五、第六个月


好吧,上个月的3个机会,全都没有等来,继续面试。心态有点躺平,焦虑感少了,颓废感来了,BOSS直聘岗位几乎能投的都投过了,没有面试的日子,会过得略显浑浑噩噩,不知道要做什么。
陆续来了几个offer,也终于决定下来了,降薪差不多40%,但好在稳定性应该有保障。


心态的转变

  • 从渴望周末,到期盼工作日

    工作时渴望周末的休息 ,没找到工作时,每一个周末的到来,都意味着本周没有结果,而过完周末,意味着过完了1/4月。感觉日子过得好快,以前按天过,现在按周过,半年时间感觉也只是弹指一挥间。
    每一个周一的到来,意味着拥抱新的机会。每周的面试频率比较高时,会感到更充实;面试频率低下来时,焦虑感会时不时的涌上心头,具体表现是狂刷招聘软件,尝试多投递几个职位。

  • 肯定 -> 否定 -> 肯定

    找工作初期,信心满满。定制计划,每天刷多少题,每天看什么知识点,应该按照什么节奏投递简历,自己全都规划好了
    中期,备受打击,总有答不上来的问题,有些之前看过的知识点,临场也会突然忘记,感觉太糟糕了。
    后期,受的打击多了,自己不会的越来越少,信心又回来了


可能能解决你的问题


要不要和家里人说


自己这半年下来,没有和家里人说,每周还是固定时间给家里打电话,为了模拟之前在路边遛弯打电话,每次电话都会坐在阳台。

个人情况是家在北方,本人在南方,和爸妈说了只能徒增他们的焦虑,所以我先瞒着了。


被裁员,是你的问题吗?


在找工作的初期,总会这样问自己,是不是自己选错了行业,是不是自己不该跳槽,会陷入一种自责的懊恼情绪。请记住,你没有任何问题,你被裁员是公司的损失,你不需要为此担责,你需要做的是让自己更强,不管是心理、身体还是技术。


用什么招聘软件


我用了BOSS直聘和猎聘两个,建议准备好了的话,可以再多搞几个平台海投。另外需要注意几点:

  1. 招聘者很久没上线,对应岗位应该是不招的
  2. 猎聘官方会不定期打电话推荐岗位,个人感觉像是完成打电话KPI,打完电话或加完微信后就没有后续跟进消息了
  3. 你看岗位信息,招聘者能看到你的查看记录,如果对某个岗位感兴趣,怕忘记JD要求,可以截图保存,避免暴露特别感兴趣的想法被压价

在哪复习


除非你已经有在家里持续专注学习的习惯,否则不管你有没有自己的书房,建议还是去找一个自习室图书馆,在安静的氛围中,你会更加高效、更加专注。

如果只能在家里复习,那么远离你的手机,把手机放到其他房间,并确保有电话你能听到,玩手机会耗费你的专注力和执行力。

(你在深圳的话,可以试试 南山书房 ,在公众号可以预约免费自习室,一次两小时)


如何度过很丧的阶段


多多少少都会有非常沮丧的阶段,可能是心仪的offer最终没有拿到手,可能是某些知识点掌握不牢的自我批判。

沮丧需要一个发泄的出口,可以保持运动习惯,比如日常爬楼梯、跑步等,一场大汗淋漓后,又是一个打满鸡血积极向上的你。

不要总在家待着,要想办法出门,多建立与社会的联系,社会在一直进步,你也不能落下。


一些建议


1. 项目经历


讲清楚几点:

  • 项目背景

    让人明白项目解决了什么问题,大概是怎么流转的,如果做的比较垂类,还需要用通俗易懂的话表达项目中的各个模块。

  • 你在其中参与的角色

    除了开发之外,是否还承担了运维、项目管理等职责,分别做了什么

  • 取得的成果

    你的高光时刻,比如解决了线上内存泄漏问题、消息堆积问题、提升了多少QPS等,通常这些亮点会被拿出来单独问,所以成果相关的延展问题也需要提前想好


还比较重要的是,通过项目介绍,引导面试官的问题走向,面试只通过几十分钟的时间来对你做出评价,其实不够客观,你需要做的是在这几十分钟的时间内尽可能的放大你的优势



除此之外,还需要做项目的延展思考



比如我自己,刚工作时做客户端开发,负责客户端埋点模块的重构,面试时被问到,“如果让你设计一个埋点服务端系统,你会考虑哪些方面”? 对于这类问题,个人感觉需要在场景设计类题目下功夫,需要了解诸如秒杀抢购等场景的架构实现方案,以及方案解决的痛点问题,这类问题往往需要先提炼痛点问题,再针对痛点问题做优化。


2. 知识点建议


推荐两个知识点网站,基本能涵盖80%的面试知识点,通读后基本能实现知识点体系化

常用八股 -- JavaGuide

操作系统、网络、MYSQL、Redis -- 小林coding


知识成体系,做思维导图进行知识记忆

那么多知识点,你是不可能全都记全的,部分知识点即使滚瓜烂熟了,半个月后基本也就忘光了。让自己的知识点成框架、成体系,比如Redis的哨兵模式是怎么做的,就需要了解到因为要确保更高的可用性,引入了主备模式,而主备模式不能自动进行故障切换,所以引入了哨兵模式做故障切换。

不要主观认为某个知识点不会被问到

不要跳过任何一个知识点,不能一味的把认为不重要的知识点往后放,因为放着放着可能就不会去看了。建议对于此类知识点,先做一个略读,做到心中大概有数,细节不必了解很清楚,之后有空再对细节查漏补缺。

之前看到SPI章节,本能认为不太重要,于是直接略过,面试中果然被问到(打破双亲委派模型的方式之一),回过头再去看,感觉其实不难,别畏惧任何一个知识点。

理论结合实践

不能只背理论,需要结合实践,能实践的实践,不能实践的最好也看看别人的实现过程。

比如线程顺序打印,看知识点你能知道可以使用join、wait/notify、condition、单线程池等方式完成,但如果面试突然让你写,对于api不熟可能还是写不出。

又比如一些大型系统的搭建,假如是K8S,你自己的机器或者云服务器没有足够的资源支撑一整套系统的搭建,那么建议找一篇别人操作的博客细品。

不要强关联知识点

被面试官问到一些具体问题,不要强行回答知识点,可能考察的是一个线上维护经验,此时答知识点可能给面试官带来一个理论帝,实操经验弱的感觉。

举两个例子,被问过线上环境出问题了,第一时间要如何处理?,本能的想到去看告警、基于链路排查工具排查是哪个环节出了问题,但实际面试官想得到的答案是版本回滚,第一时间排查出问题前做了什么更新动作,并做相应动作的回滚;又被问过你调用第三方服务失败了,如何本地排查问题?,面试官想考察的是telnet命令,因为之前出现过网络环境切换使用不同hosts配置,自己回答的是查看DNS等问题,这个问题问的并不漂亮,但是也反映出强关联知识点的问题。

建立自己的博客,并长期更新

养成写博客的习惯,记录自己日常遇到的问题,日常的感受,对于某些知识点的深度解析等。面试的几十分钟,你的耐心,你解决问题的能力,没办法完全展示,这时候甩出一个持续更新的博客,一定是很好的加分项。同时当你回顾时,也是你留下的积累和痕迹。



半年很长,但放在一生来看却又很短

不管环境怎样,希望你始终向前,披荆斩棘

如果你也正在经历这个阶段,希望你早日上岸


作者:雪梨酒色托帕石
链接:https://juejin.cn/post/7274229908314308666
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么别人的 hooks 里面有那么多的 ref

前言 最近因为一些原因对useCallback有了一些新的认知,然后遇到一个朋友说他面试的时候被问到如何实现一个倒计时的hooks的时候,想到了这个问题,突然的茅塞顿开,对react 中的hooks有了新的了解,所以有了这篇文章,记录一下我个人的想法。 在...
继续阅读 »

前言



最近因为一些原因对useCallback有了一些新的认知,然后遇到一个朋友说他面试的时候被问到如何实现一个倒计时的hooks的时候,想到了这个问题,突然的茅塞顿开,对react 中的hooks有了新的了解,所以有了这篇文章,记录一下我个人的想法。



在学习一些开源的库的时候,很容易发现开源库中 hooks 里面会写很多的 ref 来存储hooks的参数。


使用了 ref 之后,使用变量的地方就需要 .current 才能拿到变量的值,这比我直接使用变量肯定是变得麻烦了。对于有代码洁癖的人来说,这肯定是很别扭的。


但是在开源库的 hooks 中频繁的使用了 ref,这肯定不是一个毫无原因的点,那么究竟是什么原因,让开源库也不得不使用 .current 去获取变量呢?


useCallback


先跑个题,什么时候我们需要使用 useCallback 呢?


每个人肯定有每个人心中的答案,我来讲讲我的心路历程吧。


第一阶段-这是个啥


刚开始学react的时候,写函数式组件,我们定义函数的时候,肯定是不会有意识的把这个函数使用 useCallback 包裹。就这样写了一段时间的代码。


突然有一天我们遇到了一个问题,useEffect无限调用,找了半天原因,原来是因为我们还不是很清楚useEffect依赖的概念,把使用到的所有的变量一股脑的塞到了依赖数组里面,碰巧,我们这次的依赖数组里面有一个函数,在react每一次渲染的时候,函数都被重新创建了,导致我们的依赖每一次都是新的,然后就触发了无限调用。


百度了一圈,原来使用 useCallback 缓存一下这个函数就可以了,这样useEffect中的依赖就不会每一次都是一个新值了。


小总结: 在这个阶段,我们第一次使用 useCallback ,了解到了它可以缓存一个函数。


第二阶段-可以缓存


可以缓存就遇到了两个点:

  1. 缓存是吧,不会每一次都重新创建是吧,这样是不是性能就能提高了!那我把我所有用到的函数都使用 useCallback缓存一下。
  2. react 每一次render的时候会导致子组件重新渲染,使用memo可以缓存这个子组件,在父组件更新的时候,会浅层的比较子组件的props,所以传给子组件的函数就需要使用缓存useCallback起来,那么父组件中定义函数的时候图方便,一股脑的都使用 useCallback缓存。

小总结: 在这里我们错误的认为了缓存就能够帮助我们做一些性能优化的事情,但是因为还不清楚根本的原因,导致我们很容易就滥用 useCallback


第三阶段-缓存不一定是好事


在这个阶段,写react也有一段时间了,我们了解到处处缓存其实还不如不缓存,因为缓存的开销不一定就比每一次重新创建函数的开销要小。


在这里肯定也是看了很多介绍 useCallback的文章了,推荐一下下面的文章


how-to-use-memo-use-callback,这个是全英文的,掘金有翻译这篇文章的,「好文翻译」


小总结: 到这里我们就大概的意识到了,处处使用useCallback可能并不是我们想象的那样,对正确的使用useCallback有了一定的了解


总结


那么究竟在何时应该使用useCallback呢?

  1. 我们知道 react 在父组件更新的时候,会对子组件进行全量的更新,我们可以使用 memo对子组件进行缓存,在更新的时候浅层的比较一下props,如果props没有变化,就不会更新子组件,那如果props中有函数,我们就需要使用 useCallback缓存一下这个父组件传给子组件的函数。
  2. 我们的useEffect中可能会有依赖函数的场景,这个时候就需要使用useCallback缓存一下函数,避免useEffect的无限调用

是不是就这两点呢?那肯定不是呀,不然就和我这篇文章的标题联系不起来了吗。


针对useEffect这个hooks补充一点react官方文档里面有提到,建议我们使用自定义的 hooks 封装 useEffect

  • 那使用useCallback的第三个场景就出现了,就是我们在自定义hooks需要返回函数的时候,建议使用 useCallback缓存一下,因为我们不知道用户拿我们返回的函数去干什么,万一他给加到他的useEffect的依赖里面不就出问题了嘛。

一个自定义hook的案例



实现一个倒计时 hooks



需求介绍


我们先简单的实现一个倒计时的功能,就模仿我们常见的发短息验证码的功能。页面效果




app.jsx




MessageBtn.jsx



 功能比较简单,按钮点击的时候创建了一个定时器,然后时间到了就清除这个定时器。


现在把 MessageBtn 中倒计时的逻辑写到一个自定义的hooks里面。


useCountdown


把上面的一些逻辑抽取一下,useCountdown主要接受一个倒计时的时长,返回当前时间的状态,以及一个开始倒计时的函数




这里的start函数用了useCallback,因为我们不能保证用户的使用场景会不会出问题,所以我们包一下


升级 useCountdown


现在我们期望useCountdown支持两个函数,一个是在倒计时的时候调用,一个是在倒计时结束的时候调用


预期的使用是这样的,通过一个配置对象传入 countdownCallBack函数和onEnd




改造 useCountdown

  • 然后我们这里count定义 0 有点歧义,0 不能准确的知道是一开始的 0 还是倒计时结束的 0,所以还需要加一个标志位来表示当前是结束的 0
    1. 使用 useEffect监听count的变化,变化的时候触发对应的方法

    实现如下, 新增了红框的内容




    提出问题


    那么,现在就有一个很严重的问题,onEndcountdownCallBack这两个函数是外部传入的,我们要不要把他放到我们自定义hookuseEffect依赖项里面呢


    我们不能保证外部传入的变量一定是一个被useCallback包裹的函数,那么肯定就不能放到useEffect依赖项里面。


    如何解决这个问题呢?


    答案就是使用useRef。(兜兜转转这么久才点题 (╥╯^╰╥))


    用之前我们可以看一下成熟的方案是什么


    比如ahooks里面的useLatestuseMemoizedFn的实现

    • useLatest 源码


    • useMemoizedFn 源码,主要看圈起来的地方就好了,本质也是用useRef记录传入的内容



    ok,我们使用一下 useLatest 改造一下我们的useCountdown,变动点被红框圈起来了




    总结


    其实这篇文章的核心点有两个

    1. 带着大家重新的学习了一下useCallback的使用场景。(useMemo类似)
    2. 编写自定义hooks时候,我们需要注意一下外部传入的参数,以及我们返回给用户的返回值,核心点是决不相信外部传入的内容,以及绝对要给用户一个可靠的返回值。

    作者:既见君子
    链接:https://juejin.cn/post/7271643757640007680
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    谁叫你是外包呢!!!

    好吧,我是标题党,我没有看不起外包的意思。主要是想和大家聊一聊外包工作值不值得做,以及我的一些建议 最近,某匿名平台上刷消息,发现好多大厂毕业的再问要不要加入外包,还有985毕业也有开始加入外包的。就想聊聊这个话题。 外包代码量比正式员工多多了 几年前,公司...
    继续阅读 »

    好吧,我是标题党,我没有看不起外包的意思。主要是想和大家聊一聊外包工作值不值得做,以及我的一些建议



    最近,某匿名平台上刷消息,发现好多大厂毕业的再问要不要加入外包,还有985毕业也有开始加入外包的。就想聊聊这个话题。


    外包代码量比正式员工多多了


    几年前,公司出了一个代码量的统计,然后我们就发现,外包同学的代码提交量,比正式员工多多了。平时正式员工开会沟通PPT,基建中台分任务,没有多少时间正经写代码,大部分日常需求代码也都是外包来写了。而且开会沟通,中台基建大部分工作也都是再卷PPT,代码工作自然就越来越多的交给外包同学。


    于是就出现了很奇怪的现象,公司花很大的经历去招了一个很优秀的程序员,但是这个员工很少写代码,大量时间在熟悉各种中台,研究各种中台,实现各种中台。而日常需求呢,一直缺人,于是就招外包。外包虽然能力差一点,但是平时需求的复杂度并没有减少,反而因为各种中台变得越来越难开发,导致项目中欠下的技术债越来越多。


    这个时候,就出现了内包的概念。想出这个的真是厉害。就是弄一个子公司,让子公司去招人干活,技术能力的要求就介于外包和正式之间。然后再弄一个外包可以转内包,内包可以转正式的噱头,让人上进。哎,都是为了这块技术。


    大厂有没有可能外包化


    然后老板们就发现,日常需求交给内包们,完全没有问题呀。再加上最近的降本增效,做的各种中台也没有发挥很好的提效效果,大厂们开始尝试让正式员工毕业。


    到这个时候,江湖上就开始流传,P9以下都可以外包掉。你看某宝最近的政策,不就是P9以下继续走层级晋升,而p9以上,都走组织任命了吗。想想10年前,p6已经是大牛了;5年前,p7是大牛;现在呢,你不是p8,说自己很牛,谁理你。为了让你们上进,不断的让层级贬值,就像不断让货币贬值一样。


    普京的厨子


    普京的厨子,大家都知道是谁吧。你看俄乌战争中,一直是瓦格纳冲锋陷阵吧(道听途说的,不确定是不是真的),拿下一个又一个结果,最后的结果好吗。


    如果是在一家公司,厨子就类似于外包,厨子能力很强,需要人干活的时候,一定会被重视。但是,我们要知道,被重视不一定能转正的。


    我看到过一篇文章,说的就是历史上,一个大王朝到了后期,格局相对稳定后(利益分配完了),都会开始用“外包”,因为“外包”便宜啊。一旦遇到天灾人祸,“外包”规模不断扩大,最后“外包”的实力强大了,就会自己单干。然后就是下一轮“创业”周期。具体文章找不到了,熟悉历史的应该能理解我在说什么吧。


    外包多做准备


    前面说到外包不一定能转正,转正都是噱头。我不是说外包就不要上进,不要去争取转正。人要上进,那是好事。就像很多人努力考编,努力考公,努力上岸。说实话,我蛮羡慕这种人的,积极乐观上进,永远向前。


    但是,我是说如果,一直没有成功呢,一直不受待见呢(就我呆了这么长时间大厂,就知道一个外包转正了,还不是因为能力业绩凸出)。我们是不是也要准备好plan B。这两年,经济增长低了,正式员工也焦虑的不行,即使转为正式员工,高兴个两天,又会因为新目标而焦虑了,不然可能连工作都要没有了。


    怎么办


    虽然我一直是大厂正式员工,但是回想起来,真的走过太多弯路,错过了太多机会。期间也再想职业规划怎么做,但是因为感觉公司打工福利也蛮好的,折腾什么呢。现在降本增效一来,突然之间,一切都变了。


    最后,根据我走过的弯路,给大家两个建议,大家听听就好,要不要行动,自己决定。


    随时做好跳槽的准备


    不是说年年跳槽,是随时可以跳槽。变化越来越快,意外情况随时发生,一旦毕业了,有准备比没有准备要强。即使没毕业,遇到更好的岗位,没有准备你也不敢去尝试。当然了,建议不要出国,出国太危险


    多写技术文章,对外发声,让猎头、同行知道你。和同事,前同事,猎头都搞好关系。这样你就能知道很多新招聘。不然,就知道Boss直聘,觉得上面岗位很多,但是上面的岗位哪一个不是一堆简历在投,都是多对多,相互嫌弃着,很浪费精力。


    副业


    副业!副业!副业!大家都在讲副业,但是怎么做副业,看下来私单和卖课最靠谱了。我年级大了,跳槽这个已经不怎么管用。所以我最近主要就是研究副业。最近在了解和尝试,有结论,搞明白了的,也都会在自己的公众号上发出来。AI这一波挣了一点,但是不可持续,流量莫名奇妙就没了。尝试下来能挣钱,但是并不是大家想象的那样,有一些坑,有一些技巧,还是蛮有收货的。如果有在尝试的,可以加个好友多多交流交流。


    最后


    环境已经这样了,我们能怎么办呢!走的太累,就坐下来,抬头看看天。


    回到最开始的问题,大厂毕业要不要加入外包。我觉得吧,工作吗,靠自己努力挣钱养活自己,不寒碜。但是,如果我们有更好的选择,就不会有这个问题了,不是吗。所以核心问题是,没有的选择!既然是这样,有什么好问的。


    下一次,下一次,一定要多多准备,让自己有更多选择。从纠结要不要去做外包,转变成纠结哪一个选择更好。


    扯一句


    弄了个公粽号:写代码的浩,求个关注。我走了太多太多弯路,希望能帮你少走弯路。


    作者:写代码的浩
    链接:https://juejin.cn/post/7274500909865500730
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    2个奇怪的React写法

    大家好,我卡颂。 虽然React官网用大量篇幅介绍最佳实践,但因JSX语法的灵活性,所以总是会出现奇奇怪怪的React写法。 本文介绍2种奇怪(但在某些场景下有意义)的React写法。也欢迎大家在评论区讨论你遇到过的奇怪写法。 欢迎加入人类高质量前端交流群,带...
    继续阅读 »

    大家好,我卡颂。


    虽然React官网用大量篇幅介绍最佳实践,但因JSX语法的灵活性,所以总是会出现奇奇怪怪的React写法。


    本文介绍2种奇怪(但在某些场景下有意义)的React写法。也欢迎大家在评论区讨论你遇到过的奇怪写法。


    欢迎加入人类高质量前端交流群,带飞


    ref的奇怪用法


    这是一段初看让人很困惑的代码:

    function App() {
    const [dom, setDOM] = useState(null);

    return <div ref={setDOM}></div>;
    }

    让我们来分析下它的作用。


    首先,ref有两种形式(曾经有3种):

    1. 形如{current: T}的数据结构

    2. 回调函数形式,会在ref更新、销毁时触发


    例子中的setDOMuseStatedispatch方法,也有两种调用形式:

    1. 直接传递更新后的值,比如setDOM(xxx)

    2. 传递更新状态的方法,比如setDOM(oldDOM => return /* 一些处理逻辑 */)


    在例子中,虽然反常,但ref的第二种形式和dispatch的第二种形式确实是契合的。


    也就是说,在例子中传递给refsetDOM方法,会在div对应DOM更新、销毁时执行,那么dom状态中保存的就是div对应DOM的最新值。


    这么做一定程度上实现了感知DOM的实时变化,这是单纯使用ref无法具有的能力。


    useMemo的奇怪用法


    通常我们认为useMemo用来缓存变量propsuseCallback用来缓存函数props


    但在实际项目中,如果想通过缓存props的方式达到子组件性能优化的目的,需要同时保证:

    • 所有传给子组件的props的引用都不变(比如通过useMemo

    • 子组件使用React.memo


    类似这样:

    function App({todos, tab}) {
    const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]);

    return <Todo data={visibleTodos}/>;
    }

    // 为了达到Todo性能优化的目的
    const Todo = React.memo(({data}) => {
    // ...省略逻辑
    })

    既然useMemo可以缓存变量,为什么不直接缓存组件的返回值呢?类似这样:

    function App({todos, tab}) {
    const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]);

    return useMemo(() => <Todo data={visibleTodos}/>, [visibleTodos])
    }

    function Todo({data}) {
    return <p>{data}</p>;
    }

    如此,需要性能优化的子组件不再需要手动包裹React.memo,只有当useMemo依赖变化后子组件才会重新render


    总结


    除了这两种奇怪的写法外,你还遇到哪些奇怪的React写法呢?


    作者:魔术师卡颂
    链接:https://juejin.cn/post/7210048692623114298
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    移动端的双击事件好不好用?

    web
    前言 2023年了,我不允许还有人不会自己实现移动端的双击事件。 过来,看这里,不足 50 行的代码实现的双击事件。 听笔者娓娓道来。 dblclick js原生有个dblclick双击事件,但是几乎不支持移动端。 而且,该dblclick事件在pc端鼠标双...
    继续阅读 »

    前言


    2023年了,我不允许还有人不会自己实现移动端的双击事件。


    过来,看这里,不足 50 行的代码实现的双击事件。


    听笔者娓娓道来。


    dblclick


    js原生有个dblclick双击事件,但是几乎不支持移动端。


    developer.mozilla.org_zh-CN_docs_Web_API_Element_dblclick_event.png


    而且,该dblclick事件在pc端鼠标双击时,会触发两次click与一次dblclick


    window.addEventListener('click', () => {
    console.log('click')
    });
    window.addEventListener('dblclick', () => {
    console.log('dblclick')
    });

    // 双击页面,打印:click✖️2 dblclick

    我们期望可以在移动端也能有双击事件,并且隔离单击与双击事件,双击时只触发双击事件,只执行双击回调函数,让注册双击事件像注册原生事件一样简单。


    点击穿透


    简单聊聊移动端的点击穿透。



    在移动端单击会依次触发touchstart->touchmove->touchend->click事件。



    有这样一段逻辑,在touchstart时出现全屏弹框,在click弹框时关闭弹框。实际上,在点击页面时,弹框会一闪而过,并没有出现正确的交互。在移动端单击时touchstart早于click,当弹框出现了,后来的click事件就落在了弹框上,导致弹框被关闭。这就是点击穿透的一种表现。


    笔者的业务需求是双击元素,出现全屏弹框,单击弹框时关闭弹框。因此基于这样的业务需求与现实的点击穿透问题,笔者选择采用click事件来模拟双击事件,并且适配pc端使用。大家也可以选择解决点击穿透问题,并采用touchstart模拟双击事件,可以更快地响应用户操作。



    采用touchstart模拟时,可以再考虑排除双指点击的情况。


    在实现上与下文代码除了事件对象获取位置属性有所不同外,其它代码基本一致,实现思路无差别。



    模拟双击事件


    采用click事件来模拟实现双击。


    双击事件定义:2次点击事件间隔小于200ms,并且点击范围小于10px的视为双击。这里的双击事件是自定义事件,为了区分原生的 dblclick,又优先满足移动端使用,则事件名定义为 dbltouch,后续可以使用window.addEventListener('dbltouch', ()=>{})来监听双击事件。



    这个间隔与位移限制大家可以根据自己的业务需求调整。通常采用的是300ms的间隔与10px的位移,笔者业务中发现200ms间隔也可使用。


    自定义事件名大家可以随意设置,满足语义化即可。





    1. 监听click事件,并在捕获阶段监听,目的是为了后续能够阻止click事件传播。


      window.addEventListener('click', handler, true);



    2. 监听函数中,第1次点击时,记录点击位置,并设置200ms倒计时。如果第2次点击在200ms后,则重新派发当前事件,让事件继续传播,使其它的监听函数可以继续处理对应事件。


      // 标识是否在等待第2次点击
      let isWaiting = false;

      // 记录点击位置
      let prevPosition = {};

      function handler(evt) {
      const { pageX, pageY } = evt;
      prevPostion = { pageX, pageY };
      // 阻止冒泡,不让事件继续传播
      evt.stopPropagation();
      // 开始等待第2次点击
      isWaiting = true;
      // 设置200ms倒计时,200ms后重新派发当前事件
      timer = setTimeout(() => {
      isWaiting = false;
      evt.target.dispatchEvent(evt);
      }, 200)
      }

      注意: 倒计时结束时evt.target.dispatchEvent(evt)派发的事件仍是原来的事件对象,即仍是click事件,会触发继续handler函数,进入了循环。


      这里需要破局,已知Event事件对象下有一个 isTrusted 属性,是一个只读属性,是一个布尔值。当事件是由用户行为生成的时候,这个属性的值为 true ,而当事件是由脚本创建、修改、通过 EventTarget.dispatchEvent()派发的时候,这个属性的值为 false 。


      因此,此处脚本派发的事件是希望继续传递的事件,不用handler内处理。


      function handler(evt) {
      // 如果事件是脚本派发的则不处理,将该事件继续传播
      if(!evt.isTrusted){
      return;
      }
      }



    3. 处理完第1次点击后,接着处理在200ms内的第2次点击事件。如果满足位移小于10px的条件,则视为双击。


      // 标识是否在等待第2次点击
      let isWaiting = false;

      // 记录点击位置
      const prevPosition = {};

      function handler(evt) {
      // 如果事件是脚本派发的则不处理,将该事件继续传播
      if(!evt.isTrusted){
      return;
      }
      const { pageX, pageY } = evt;
      if(isWaiting) {
      isWaiting = false;
      const diffX = Math.abs(pageX - prevPosition.pageX);
      const diffY = Math.abs(pageY - prevPosition.pageY);
      // 如果满足位移小于10,则是双击
      if(diffX <= 10 && diffY <= 10) {
      // 取消当前事件传递,并派发1个自定义双击事件
      evt.stopPropagation();
      evt.target.dispatchEvent(
      new PointerEvent('dbltouch', {
      cancelable: false,
      bubbles: true,
      })
      )
      }
      } else {
      prevPostion = { pageX, pageY };
      // 阻止冒泡,不让事件继续传播
      evt.stopPropagation();
      // 开始等待第2次点击
      isWaiting = true;
      // 设置200ms倒计时,200ms后重新派发当前事件
      timer = setTimeout(() => {
      isWaiting = false;
      evt.target.dispatchEvent(evt);
      }, 200)
      }
      }



    4. 以上便实现了双击事件,全局任意地方监听双击。


      window.addEventListener('dbltouch', () => {
      console.log('dbltouch');
      })
      window.addEventListener('click', () => {
      console.log('click');
      })
      // 使用鼠标、手指双击
      // 打印出 dbltouch
      // 而且不会打印有click



    笔者要在这里说句 但是: 由于200ms的延时,虽不多,但是对于操作迅速的用户来讲,还是会有不好的体验。


    优化双击事件


    由于是在window上注册的click函数,虽说注册双击事件像单击事件一样简单了,但却也导致整个产品页面的click事件都会推迟200ms执行。


    因此,我们应该只对需要处理双击的地方添加双击事件,至少只在局部发生延迟情况。稍微调整下代码,将需要注册双击事件的元素由开发决定,通过参数传递。而且事件处理函数也可以通过参数传递,即可以通过监听双击事件,也可以通过回调函数执行。


    以下是完整的代码。


    class RegisterDbltouchEvent {
    constructor(el, fn) {
    this.el = el || window;
    this.callback = fn;
    this.timer = null;
    this.prevPosition = {};
    this.isWaiting = false;

    // 注册click事件,注意this指向
    this.el.addEventListener('click', this.handleClick.bind(this), true);
    }
    handleClick(evt){
    if(this.timer) {
    clearTimeout(this.timer);
    this.timer = null;
    }
    if(!evt.isTrusted) {
    return;
    };
    if(this.isWaiting){
    this.isWaiting = false;
    const diffX = Math.abs(pageX - this.prevPosition.pageX);
    const diffY = Math.abs(pageY - this.prevPosition.pageY);
    // 如果满足位移小于10,则是双击
    if(diffX <= 10 && diffY <= 10) {
    // 取消当前事件传递,并派发1个自定义双击事件
    evt.stopPropagation();
    evt.target.dispatchEvent(
    new PointerEvent('dbltouch', {
    cancelable: false,
    bubbles: true,
    })
    );
    // 也可以采用回调函数的方式
    this.callback && this.callback(evt);
    }
    } else {
    this.prevPostion = { pageX, pageY };
    // 阻止冒泡,不让事件继续传播
    evt.stopPropagation();
    // 开始等待第2次点击
    this.isWaiting = true;
    // 设置200ms倒计时,200ms后重新派发当前事件
    this.timer = setTimeout(() => {
    this.isWaiting = false;
    evt.target.dispatchEvent(evt);
    }, 200)
    }
    }
    }

    只为需要实现双击逻辑的元素注册双击事件。可以通过传递回调函数的方式执行业务逻辑,也可以通过监听dbltouch事件的方式,也可以同时使用,it's up to you.


    const el = document.querySelector('#dbltouch');
    new RegisterDbltouchEvent(el, (evt) => {
    // 实现双击逻辑
    })

    最后


    采用的click事件模拟双击事件,因此在移动端和pc端都可以使用该构造函数。


    作者:Yue栎廷
    来源:juejin.cn/post/7274043371731796003
    收起阅读 »

    为什么我的页面鼠标一滑过,布局就错乱了?

    web
    前言 这天刚到公司,测试同事又在群里@我: 为什么页面鼠标一滑过,布局就错乱了? 以前是正常的啊? 刷新后也是一样 快看看怎么回事 同时还给发了一段bug复现视频,我本地跑个例子模拟下 可以看到,鼠标没滑过是正常的,鼠标一滑过图片就换行了,鼠标移出又正常了。...
    继续阅读 »

    前言


    这天刚到公司,测试同事又在群里@我:

    为什么页面鼠标一滑过,布局就错乱了?

    以前是正常的啊?

    刷新后也是一样

    快看看怎么回事


    同时还给发了一段bug复现视频,我本地跑个例子模拟下


    GIF 2023-8-28 11-23-25.gif


    可以看到,鼠标没滑过是正常的,鼠标一滑过图片就换行了,鼠标移出又正常了。


    正文


    首先说下我们的产品逻辑,我们产品的需求是:要滚动的页面,滚动条不应该占据空间,而是悬浮在滚动页面上面,而且滚动条只在滚动的时候展示。


    我们的代码是这样写:


      <style>
    .box {
    width: 630px;
    display: flex;
    flex-wrap: wrap;
    overflow: hidden; /* 注意⚠️ */
    height: 50vh;
    box-shadow: 0 0 5px rgba(93, 96, 93, 0.5);
    }
    .box:hover {
    overflow: overlay; /* 注意⚠️ */
    }
    .box .item {
    width: 200px;
    height: 200px;
    margin-right: 10px;
    margin-bottom: 10px;
    }
    img {
    width: 100%;
    height: 100%;
    }
    </style>
    <div class="box">
    <div class="item">
    <img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
    </div>
    <div class="item">
    <img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
    </div>
    <div class="item">
    <img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
    </div>
    <div class="item">
    <img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
    </div>
    </div>

    我们使用了overflow属性的overlay属性,滚动条不会占据空间,而是悬浮在页面之上,刚好吻合了产品的需求。


    image.png


    然后我们在滚动的区域默认是overflow:hidden,正常时候不会产生滚动条,hover的时候把overflow改成overlay,滚动区域可以滚动,并且不占据空间。


    简写代码如下:


      .box {
    overflow: hidden;
    }
    .box:hover {
    overflow: overlay;
    }

    然后我们是把它封装成sass的mixins,滚动区域使用这个mixins即可。


    上线后没什么问题,符合预期,获得产品们的一致好评。


    直接这次bug的出现。


    排查


    我先看看我本地是不是正常的,我打开一看,咦,我的布局不会错乱。


    然后我看了我的chrome的版本,是113版本


    然后我问了测试的chrome版本,她是114版本


    然后我把我的chrome升级到最新114版本,咦,滑过页面之后我的布局也乱了。


    初步判断,那就有可能是chrome版本的问题。


    去网上看看chrome的升级日志,看看有没有什么信息。


    image.png


    具体说明:


    image.png


    可以看到chrome114有了一个升级,就是把overflow:overlay当作overflow:auto的别名,也就是说,overlay的表现形式和auto是一致的。


    实锤了,auto是占据空间的,所以导致鼠标一滑过,布局就乱了。


    其实我们从mdn上也能看到,它旁边有个删除按钮,然后滑过有个tips:


    image.png


    解决方案


    第一种方式


    既然overflow:overlay表现形式和auto一致,那么我们得事先预留出滚动条的位置,然后设置滚动条的颜色是透明的,滑过才显示颜色,基本上也能符合产品的需求。


    代码如下:


      // 滚动条
    ::-webkit-scrollbar {
    background: transparent;
    width: 6px;
    height: 6px;
    }
    // 滚动条上的块
    ::-webkit-scrollbar-thumb {
    background-clip: padding-box;
    background-color: #d6d6d6;
    border: 1px solid transparent;
    border-radius: 10px;
    }
    .box {
    overflow: auto;
    }
    .box::-webkit-scrollbar-thumb {
    background-color: transparent;
    }
    .box:hover::-webkit-scrollbar-thumb {
    background-color: #d6d6d6;
    }

    第二种方式


    如果有用element-ui,它内部封装了el-scrollbar组件,模拟原生的滚动条,这个也不占据空间,而是悬浮,并且默认不显示,滑过才显示。



    element-ui没有el-scrollbar组件的文档说明,element-plus才有,不过都可以用。



    总结


    这次算是踩了一个坑,当mdn上面提示有些属性已经要废弃⚠️了,我们就要小心了,尽量不要使用这些属性,就算当前浏览器还是支持的,但是不能保证未来某个时候浏览器会不会废弃这个属性。(比如这次问题,改这些问题挺费时间的,因为用的地方挺多的。)


    因为一旦这些属性被废弃了,它预留的bug就等着你去解决,谨记!


    作者:答案cp3
    来源:juejin.cn/post/7273875079658209319
    收起阅读 »

    Java切换到Kotlin,Crash率上升了?

    前言 最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。 通过本篇文章...
    继续阅读 »

    前言


    最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。

    通过本篇文章,你将了解到:

    1. NPE(空指针 NullPointerException)的本质
    2. Java 如何预防NPE?
    3. Kotlin NPE检测
    4. Java/Kotlin 混合调用
    5. 常见的Java/Kotlin互调场景


    1. NPE(空指针 NullPointerException)的本质


    变量的本质


        val name: String = "fish"

    name是什么?

    对此问题你可能嗤之以鼻:



    不就是变量吗?更进一步说如果是在对象里声明,那就是成员变量(属性),如果在方法(函数)里声明,那就是局部变量,如果是静态声明的,那就是全局变量。



    回答没问题很稳当。

    那再问为什么通过变量就能找到对应的值呢?



    答案:变量就是地址,通过该地址即可寻址到内存里真正的值



    无法访问的地址



    在这里插入图片描述


    如上图,若是name="fish",表示的是name所指向的内存地址里存放着"fish"的字符串。

    若是name=null,则说明name没有指向任何地址,当然无法通过它访问任何有用的信息了。


    无论C/C++亦或是Java/Kotlin,如果一个引用=null,那么这个引用将毫无意义,无法通过它访问任何内存信息,因此这些语言在设计的过程中会将通过null访问变量/方法的行为都会显式(抛出异常)提醒开发者。


    2. Java 如何预防NPE?


    运行时规避


    先看Demo:


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).test();
    }

    void test() {
    String str = getString();
    System.out.println(str.length());
    }

    String getString() {
    return null;
    }
    }

    执行上述代码将会抛出异常,导致程序Crash:



    在这里插入图片描述


    我们有两种解决方式:




    1. try...catch

    2. 对象判空



    try...catch 方式


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).testTryCatch();
    }

    void testTryCatch() {
    try {
    String str = getString();
    System.out.println(str.length());
    } catch (Exception e) {
    }
    }

    String getString() {
    return null;
    }
    }

    NPE被捕获,程序没有Crash。


    对象判空


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).testJudgeNull();
    }

    void testJudgeNull() {
    String str = getString();
    if (str != null) {
    System.out.println(str.length());
    }
    }

    String getString() {
    return null;
    }
    }

    因为提前判空,所以程序没有Crash。


    编译时检测


    在运行时再去做判断的缺点:



    无法提前发现NPE问题,想要覆盖大部分的场景需要随时try...catch或是判空
    总有忘记遗漏的时候,发布到线上就是个生产事故



    那能否在编译时进行检测呢?

    答案是使用注解。


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).test();
    }

    void test() {
    String str = getString();
    System.out.println(str.length());
    }

    @Nullable String getString() {
    return null;
    }
    }

    在编写getString()方法时发现其可能为空,于是给方法加上一个"可能为空"的注解:@Nullable


    当调用getString()方法时,编译器给出如下提示:



    在这里插入图片描述


    意思是访问的getString()可能为空,最后访问String.length()时可能会抛出NPE。

    看到编译器的提示我们就知道此处有NPE的隐患,因此可以针对性的进行处理(try...catch或是判空)。


    当然此处的注解仅仅只是个"弱提示",你即使没有进行针对性的处理也能编译通过,只是问题最后都流转到运行时更难挽回了。


    有"可空"的注解,当然也有"非空"的注解:



    在这里插入图片描述


    @Nonnull 注解修饰了方法后,若是检测到方法返回null,则会提示修改,当然也是"弱提示"。


    3. Kotlin NPE检测


    编译时检测


    Kotlin 核心优势之一:



    空安全检测,变量分为可空型/非空型,能够在编译期检测潜在的NPE,并强制开发者确保类型一致,将问题在编译期暴露并解决



    先看非空类型的变量声明:


    class TestKotlin {

    fun test() {
    val str = getString()
    println("${str.length}")
    }

    private fun getString():String {
    return "fish"
    }
    }

    fun main() {
    TestKotlin().test()
    }


    此种场景下,我们能确保getString()函数的返回一定非空,因此在调用该函数时无需进行判空也无需try...catch。


    你可能会说,你这里写死了"fish",那我写成null如何?



    在这里插入图片描述


    编译期直接提示不能这么写,因为我们声明getString()的返回值为String,是非空的String类型,既然声明了非空,那么就需要言行一致,返回的也是非空的。


    有非空场景,那也得有空的场景啊:


    class TestKotlin {

    fun test() {
    val str = getString()
    println("${str.length}")
    }

    private fun getString():String? {
    return null
    }
    }

    fun main() {
    TestKotlin().test()
    }

    此时将getString()声明为非空,因此可以在函数里返回null。

    然而调用之处就无法编译通过了:



    在这里插入图片描述


    意思是既然getString()可能返回null,那么就不能直接通过String.length访问,需要改为可空方式的访问:


    class TestKotlin {

    fun test() {
    val str = getString()
    println("${str?.length}")
    }

    private fun getString():String? {
    return null
    }
    }

    str?.length 意思是:如果str==null,就不去访问其成员变量/函数,若不为空则可以访问,于是就避免了NPE问题。


    由此可以看出:



    Kotlin 通过检测声明与实现,确保了函数一定要言行一致(声明与实现),也确保了调用者与被调用者的言行一致



    因此,若是用Kotlin编写代码,我们无需花太多时间去预防和排查NPE问题,在编译期都会有强提示。


    4. Java/Kotlin 混合调用


    回到最初的问题:既然Kotlin都能在编译期避免了NPE,那为啥使用Kotlin重构后的代码反而导致Crash率上升呢?


    原因是:项目里同时存在了Java和Kotlin代码,由上可知两者在NPE的检测上有所差异导致了一些兼容问题。


    Kotlin 调用 Java


    调用无返回值的函数


    Kotlin虽然有空安全检测,但是Java并没有,因此对于Java方法来说,不论你传入空还是非空,在编译期我都没法检测出来。


    public class TestJava {
    void invokeFromKotlin(String str) {
    System.out.println(str.length());
    }
    }

    class TestKotlin {

    fun test() {
    TestJava().invokeFromKotlin(null)
    }
    }

    fun main() {
    TestKotlin().test()
    }

    如上无论是Kotlin调用Java还是Java之间互调,都没法确保空安全,只能由被调用者(Java)自己处理可能的异常情况。


    调用有返回值的函数


    public class TestJava {
    public String getStr() {
    return null;
    }
    }

    class TestKotlin {
    fun testReturn() {
    println(TestJava().str.length)
    }
    }

    fun main() {
    TestKotlin().testReturn()
    }

    如上,Kotlin调用Java的方法获取返回值,由于在编译期Kotlin无法确定返回值,因此默认把它当做非空处理,若是Java返回了null,那么将会Crash。


    Java 调用 Kotlin


    调用无返回值的函数


    先定义Kotlin类:


    class TestKotlin {

    fun testWithoutNull(str: String) {
    println("len:${str.length}")
    }

    fun testWithNull(str: String?) {
    println("len:${str?.length}")
    }
    }

    有两个函数,分别接收可空/非空参数。


    在Java里调用,先调用可空函数:


    public class TestJava {
    public static void main(String args[]) {
    (new TestKotlin()).testWithNull(null);
    }
    }

    因为被调用方是Kotlin的可空函数,因此即使Java传入了null,也不会有Crash。


    再换个方式,在Java里调用非空函数:


    public class TestJava {
    public static void main(String args[]) {
    (new TestKotlin()).testWithoutNull(null);
    }
    }

    却发现Crash了!



    在这里插入图片描述


    为什么会Crash呢?反编译查看Kotlin代码:


    public final class TestKotlin {
    public final void testWithoutNull(@NotNull String str) {
    Intrinsics.checkNotNullParameter(str, "str");
    String var2 = "len:" + str.length();
    System.out.println(var2);
    }

    public final void testWithNull(@Nullable String str) {
    String var2 = "len:" + (str != null ? str.length() : null);
    System.out.println(var2);
    }
    }

    对于非空的函数来说,会有检测代码:

    Intrinsics.checkNotNullParameter(str, "str"):


        public static void checkNotNullParameter(Object value, String paramName) {
    if (value == null) {
    throwParameterIsNullNPE(paramName);
    }
    }
    private static void throwParameterIsNullNPE(String paramName) {
    throw sanitizeStackTrace(new NullPointerException(createParameterIsNullExceptionMessage(paramName)));
    }

    可以看出:




    1. Kotlin对于非空的函数参数,先判断其是否为空,若是为空则直接抛出NPE

    2. Kotlin对于可空的函数参数,没有强制检测是否为空



    调用有返回值的函数


    Java 本身就没有空安全,只能在运行时进行处理。


    小结


    很容看出来:




    1. Java 调用Kotlin的非空函数有Crash的风险,编译器无法检查到传入的参数是否为空

    2. Java 调用Kotlin的可空函数没有Crash风险,Kotlin编译期检查空安全

    3. Kotlin 调用Java的函数有Crash风险,由Java代码规避风险

    4. Kotlin 调用Java有返回值的函数有Crash风险,编译器无法检查到返回值是否为空



    回到文章的标题,我们已经大致知道了Java切换到Kotlin,为啥Crash就升上了的原因了,接下来再详细分析。


    5. 常见的Java/Kotlin互调场景


    Android里的Java代码分布



    在这里插入图片描述


    在Kotlin出现之前,Java就是Android开发的唯一语言,Android Framework、Androidx很多是Java代码编写的,因此现在依然有很多API是Java编写的。


    而不少的第三方SDK因为稳定性、迁移代价的考虑依然使用的是Java代码。


    我们自身项目里也因为一些历史原因存在Java代码。


    以下讨论的前提是假设现有Java代码我们都无法更改。


    Kotlin 调用Java获取返回值


    由于编译期无法判定Java返回的值是空还是非空,因此若是确认Java函数可能返回空,则可以通过在Kotlin里使用可空的变量接收Java的返回值。


    class TestKotlin {
    fun testReturn() {
    val str: String? = TestJava().str
    println(str?.length)
    }
    }

    fun main() {
    TestKotlin().testReturn()
    }

    Java 调用Kotlin函数


    LiveData Crash的原因与预防


    之前已经假设过我们无法改动Java代码,那么Java调用Kotlin函数的场景只有一个了:回调。

    上面的有返回值场景还是比较容易防备,回调的场景就比较难发现,尤其是层层封装之后的代码。

    这也是特别常见的场景,典型的例子如LiveData。


    Crash原因


    class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData: MutableLiveData = MutableLiveData()
    fun testLiveData() {
    liveData.observe(lifecycleOwner) {
    println(it.length)
    }
    }

    init {
    testLiveData()
    }
    }

    如上,使用Kotlin声明LiveData,其类型是非空的,并监听LiveData的变化。


    在另一个地方给LiveData赋值:


    TestKotlin(this@MainActivity).liveData.value = null

    虽然LiveData的监听和赋值的都是使用Kotlin编写的,但不幸的是还是Crash了。

    发送和接收都是用Kotlin编写的,为啥还会Crash呢?

    看看打印:



    在这里插入图片描述


    意思是接收到的字符串是空值(null),看看编译器提示:



    在这里插入图片描述


    原来此处的回调传入的值被认为是非空的,因此当使用it.length访问的时候编译器不会有空安全提示。


    再看看调用的地方:



    在这里插入图片描述


    可以看出,这回调是Java触发的。


    Crash 预防


    第一种方式:

    我们看到LiveData的数据类型是泛型,因此可以考虑在声明数据的时候定为非空:


    class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData = MutableLiveData()
    fun testLiveData() {
    liveData.observe(lifecycleOwner) {
    println(it?.length)
    }
    }

    init {
    testLiveData()
    }
    }

    如此一来,当访问it.length时编译器就会提示可空调用。


    第二种方式:

    不修改数据类型,但在接收的地方使用可空类型接收:


    class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData = MutableLiveData()
    fun testLiveData() {
    liveData.observe(lifecycleOwner) {
    val dataStr:String? = it
    println(dataStr?.length)
    }
    }

    init {
    testLiveData()
    }
    }

    第三种方式:

    使用Flow替换LiveData。


    LiveData 修改建议:




    1. 若是新写的API,建议使用第三种方式

    2. 若是修改老的代码,建议使用第一种方式,因为可能有多个地方监听LiveData值的变化,如果第一种方式的话需要写好几个地方。



    其它场景的Crash预防:


    与后端交互的数据结构
    比如与后端交互声明的类,后端有可能返回null,此时在客户端接收时若是使用了非空类型的字段去接收,那么会发生Crash。

    通常来说,我们会使用网络框架(如retrofit)接收数据,数据的转换并不是由我们控制,因此无法使用针对LivedData的第二种方式。

    有两种方式解决:




    1. 与后端约定,不能返回null(等于白说)

    2. 客户端声明的类的字段声明为可空(类似针对LivedData的第一种方式)



    Json序列化/反序列化

    Json字符串转换为对象时,有些字段可能为空,也需要声明为可空字段。


    小结



    在这里插入图片描述


    您若喜欢,请点赞、关注、收藏,您的鼓励是我前进的动力


    持续更新中,和我一起步步为营系统、深入学习Android/Kotlin


    作者:小鱼人爱编程
    来源:juejin.cn/post/7274163003158511616
    收起阅读 »

    聊聊Java中浮点丢失精度的事

    在说这个之前,我们先看看十进制到二进制的转换过程 整数的十进制到二进制的转换过程 用白话说这个过程就是不断的除2,得到商继续除,直到商小于1为止,然后他每次结果的余数倒着排列出来就是它的二进制结果了,直接上图 说一下为什么倒着排列就是二进制结果哈 通俗点说就...
    继续阅读 »

    在说这个之前,我们先看看十进制到二进制的转换过程


    整数的十进制到二进制的转换过程


    用白话说这个过程就是不断的除2,得到商继续除,直到商小于1为止,然后他每次结果的余数倒着排列出来就是它的二进制结果了,直接上图


    整数十进制转二进制.jpg
    说一下为什么倒着排列就是二进制结果哈


    通俗点说就是整数是一步一步除下来的,那回去不得一步一步乘上去?也就是说从上到下就是二进制从低位到高位的过程。


    小数十进制到二进制的转换过程


    小数的十进制到二进制的转换其实和整数类似,只不过算的方式变成了乘法,也就是用小数不断的乘2,然后得到的结果的整数部分拿出来,接着剩下的小数部分继续乘2,直到小数部分为0为止,直接上图~


    小数十进制转二进制过程(不循环).jpg
    二进制结果中的二分之一是转换后的,其实就是2的-1次方,-2次方。。。


    当然了,小数转二进制的过程中,很多情况下都是无尽的,接着上图


    小数十进制转二进制过程(循环).jpg
    所以可以看到这样的循环下去是得不到二进制的结果的,所以计算机就要进行取舍。也就是IEEE 754规范


    IEEE 754规范


    IEEE 754规定了四种标识浮点数值的方式,单精确度(32位),双精确度(64位),延伸单精确度(43比特以上,很少用)和延伸双精确度(79比特以上,通常80位)


    最常用的还是单精确度和双精确度,也就是对标的float和double。但是IEEE 754规范并没有解决精确标识小数的问题,只是提供了一种用近似值标识小数的方式。而且精确度不同近似值也会不同。# 为什么会精度丢失?教你看懂 IEEE-754!


    下面有个例子来看一下丢失精度的问题,如0.1+0.2
    0.1的64位二进制:0.00011001100110011001100110011001100110011001100110011001
    0.2的64位二进制:0.00110011001100110011001100110011001100110011001100110011
    二者相加的结果为:0.30000000000000004


    那么如何解决精度问题呢?


    BigDecimal


    BigDecimal使用java.math包提供的,在涉及到金钱相关的计算的时候都需要使用它,而且其中提供了大量的方法,比如加减乘除都是可以直接调用的。


    先看这个问题,BigDecimal中的比较问题


    先看下面这个例子


    public class ReferenceDemo {

    public static void main(String[] args) {

    BigDecimal bigDecimal1 = new BigDecimal(1);
    BigDecimal bigDecimal2 = new BigDecimal(1);
    System.out.println(bigDecimal1.equals(bigDecimal2));

    BigDecimal bigDecimal3 = new BigDecimal(1);
    BigDecimal bigDecimal4 = new BigDecimal(1.0);
    System.out.println(bigDecimal3.equals(bigDecimal4));

    BigDecimal bigDecimal5 = new BigDecimal("1");
    BigDecimal bigDecimal6 = new BigDecimal("1.0");

    System.out.println(bigDecimal5.equals(bigDecimal6));
    }


    }

    结果为:


    image.png
    其中第二个例子和第三个例子的不同是需要聊一聊的。为什么会出现这种呢?下面是BigDecimal中的equals的源码。


    public boolean equals(Object x) {
    if (!(x instanceof BigDecimal))
    return false;
    BigDecimal xDec = (BigDecimal) x;
    if (x == this)
    return true;
    //关键在这一行,比较了scale
    if (scale != xDec.scale)
    return false;
    long s = this.intCompact;
    long xs = xDec.intCompact;
    if (s != INFLATED) {
    if (xs == INFLATED)
    xs = compactValFor(xDec.intVal);
    return xs == s;
    } else if (xs != INFLATED)
    return xs == compactValFor(this.intVal);

    return this.inflated().equals(xDec.inflated());
    }

    由上面的注释可以看到BigDecimal中有一个很关键的属性,就是scale,标度。标度是什么?
    首先看一下BigDecimal的结构


    public class BigDecimal extends Number implements Comparable<BigDecimal> {
    /**
    * The unscaled value of this BigDecimal, as returned by {@link
    * #unscaledValue}.
    *
    * @serial
    * @see #unscaledValue
    */

    private final BigInteger intVal;

    /**
    * The scale of this BigDecimal, as returned by {@link #scale}.
    *
    * @serial
    * @see #scale
    */

    private final int scale; // Note: this may have any value, so
    // calculations must be done in longs

    /**
    * If the absolute value of the significand of this BigDecimal is
    * less than or equal to {@code Long.MAX_VALUE}, the value can be
    * compactly stored in this field and used in computations.
    */

    private final transient long intCompact;
    }

    我截取了几个关键字段,依次看一下:


    intVal: 无标度值


    scale: 标度


    intCompact: 当intVal超过阈值(默认为Long.MAX_VALUE)时,进行压缩运算,结果存到这个字段上,用于后续计算。


    注释中解释到,scale为0或者正数的时候代表数字小数点之后的位数,如果scale为负数,代表数字的无标度值需要乘10的该负数的绝对值的幂,即末尾有几个0


    比如123.123这个数,他的intVal就是123123,scale就是3了


    而二进制无法标识0.1,通过BigDecimal标识的话,它的intVal就是1,scale也是1。


    接着看回上面的例子,传入的参数是字符串的bigDecimal5和bigDecimal6,为什么就返回了false。上图


    image.png


    他们的标度是不同的,所以直接返回了false,那么在看bigDecimal3和bigDecimal4的比较,为什么就返回了true呢,同样上图


    image.png
    可以看到他们的intVal和scale都是相等的,但是明明传入了不同的,有兴趣的可以取看看源码,找一些资料,对于1.0这个数,它本质上也是一个整数,经过一系列的运算他的intVal还是1,scale还是0,所以比较之后返回的是true。


    这时候就能看出来equals方法的一些问题了,用equals涉及到scale的比较,实际的结果可能和预期不一样,所在BigDecimal的比较推荐用compareTo方法,如果返回0,代表相等


    BigDecimal bigDecimal5 = new BigDecimal("1");
    BigDecimal bigDecimal6 = new BigDecimal("1.0");

    System.out.println(bigDecimal5.compareTo(bigDecimal6));

    说到这里同时提一下,不要用传参为double的构造方法,同样会丢失精度,如果需要小数,需要传入字符串的小数来获取BigDecimal的实例对象。


    说到这其实应该明白了他是怎么保证精度的了,其实关键点就是scale,这个标度贯穿了整个过程,加减乘除的运算都需要它来把控。上面说了其实2个参数最为关键,一个是无标度值,一个是标度,无标度值就是整数了,以加法为例子,不就可以变成整数的加法了吗,然后用scale控制小数点,说是这么说,实现过程还是很复杂的,有兴趣的可以自己查资料去学习。


    除了用字符串代替double来表示BigDecimal的小数,其实也可以通过BigDecimal.valueOf()方法,它传入double之后可以和字符串一样的效果,为啥呢?上代码


    public static BigDecimal valueOf(double val) {
    // Reminder: a zero double returns '0.0', so we cannot fastpath
    // to use the constant ZERO. This might be important enough to
    // justify a factory approach, a cache, or a few private
    // constants, later.
    return new BigDecimal(Double.toString(val));
    }

    它把传入的double给toString了。。。。


    作者:yulbo
    来源:juejin.cn/post/7274692953058082877
    收起阅读 »

    JS 获取页面尺寸

    web
    通过 JS 获取页面相关的尺寸是比较常见的操作,尤其是在动态计算页面布局时,今天我们就来学习一下几个获取页面尺寸的基本方法。 获取页面高度 function getPageHeight() { var g = document, a = g.bod...
    继续阅读 »

    通过 JS 获取页面相关的尺寸是比较常见的操作,尤其是在动态计算页面布局时,今天我们就来学习一下几个获取页面尺寸的基本方法。


    获取页面高度


    function getPageHeight() {
    var g = document,
    a = g.body,
    f = g.documentElement,
    d = g.compatMode == "BackCompat" ? a : g.documentElement;
    return Math.max(f.scrollHeight, a.scrollHeight, d.clientHeight);
    }

    获取页面scrollLeft


    function getPageScrollLeft() {
    var a = document;
    return a.documentElement.scrollLeft || a.body.scrollLeft;
    }

    获取页面scrollTop


    function getPageScrollTop() {
    var a = document;
    return a.documentElement.scrollTop || a.body.scrollTop;
    }

    获取页面可视宽度


    function getPageViewWidth() {
    var d = document,
    a = d.compatMode == "BackCompat" ? d.body : d.documentElement;
    return a.clientWidth;
    }

    获取页面可视高度


    function getPageViewHeight() {
    var d = document,
    a = d.compatMode == "BackCompat" ? d.body : d.documentElement;
    return a.clientHeight;
    }

    获取页面宽度


    function getPageWidth() {
    var g = document,
    a = g.body,
    f = g.documentElement,
    d = g.compatMode == "BackCompat" ? a : g.documentElement;
    return Math.max(f.scrollWidth, a.scrollWidth, d.clientWidth);
    }

    ~


    ~ 全文完


    ~


    作者:编程三昧
    来源:juejin.cn/post/7274856158175363126
    收起阅读 »

    一个有意思的点子

    web
    前言 前端基于运行时检测问题、定位原因、预警提示的可行性思考🤔 背景 部门要治理线上故障过多的问题,故障主要分后端异常以及前端的性能问题和业务问题。我们讨论的范围是前端业务故障,经过分析可以把它们大致分为UI、业务、工程技术、日志、三方依赖等。 首先要确定...
    继续阅读 »

    前言


    前端基于运行时检测问题、定位原因、预警提示的可行性思考🤔



    背景


    部门要治理线上故障过多的问题,故障主要分后端异常以及前端的性能问题和业务问题。我们讨论的范围是前端业务故障,经过分析可以把它们大致分为UI、业务、工程技术、日志、三方依赖等。



    首先要确定降低故障的指标,MTTI和MTTD是关键,因为只有及时发现和定位问题才能快速消灭它们。我们暂时没有统计线上MTTI、MTTD的数据,因为缺乏相关的预警所以问题的发现和定位耗时通常很久,下面的一些复盘统计显示发现问题的时间可以持续甚至好几个月。



    这些问题中UI和业务异常占比超过80%,这些问题发现的不及时,一方面是问题本身不会阻碍核心链路,另一方面说明团队对业务的稳定缺少监控手段。
    现有开发流程已经包含了Design QA验收交付的UI是否符合预期;开发工程师会和QA工程师一起执行Test Case验证业务的稳定并且在CI环节还有UT的保障。既然如此那为什么线上还是会不可避免的出现故障呢?


    问题归因


    在Dev和Stage阶段的验收能发现和处理绝显而易见的异常,但是这些验收的场景是有限的



    1. 开发环境数据集的局限

    2. 考虑到AB因素的影响,很难做到全场景全业务覆盖。

    3. 开发过程可能会无意间影响到其他业务,但是我们的注意力集中在现有的业务,也就导致问题难以被发现


    所以归根到底,验收环节中数据和场景的局限以及人治导致一些Edge case被遗漏。


    解决方案


    我们该如何解决数据和场景的局限呢?这个其实通过Monkey和数据流量回放就能解决。
    运行时阶段包含了所有业务和代码的上下文,所有在这个阶段发现问题、分析原因并预警效率是最高的,人治的问题可以得到改善。下面是这种机制执行的思路



    1. 自动化测试时,通过流量回放的形式模拟线上的数据和环境尽可能多的覆盖场景。

    2. 运行时阶段,前端通过UT代码验收业务(🤔UT只能在Unit Test阶段执行,运行时执行的原理后面会讲)

    3. 运行时阶段,分析UI元素间的关系并探测异常问题


    方案实现


    方案实现仅讨论前端的部分。
    UI和业务检测比较通用,因为它们的判别条件是客观的。比如我们完全可以复用UT代码检测业务。


    自动检测、定位原因、预警


    这个机制实现没有困难。考虑到自动检测的范围在不同项目中不尽相同,所以实现的思路可以是插件的形式,模块间通过协议解耦。
    主要功能模块有:



    1. 告警模块

    2. 日志生成模块

    3. 业务注册模块(接收业务自定义的检查日志)

    4. 内嵌的UI检测模块


    UI检测


    业务不同,遇到的UI问题会有差异,这部分需要具体问题具体分析,所以不做过多讨论。针对我们业务的现状Overlap、Truncate、Clip在UI中占比较高。我的做法是对显示的视图按多叉树遍历到叶子节点并分析子节点和兄弟节点间的关系,找到Overlap、Truncate、Clip问题。具体的实现可以参考代码LensWindowGuard.swift:31


    业务检测


    UT代码从逻辑上可以被分为三个部分:



    1. Give

    2. When

    3. Then


    Given表示输入的数据,可以是真实接口也可以是Mock数据。


    When表示调用业务函数,同时这里会产生一个输出结果。


    Then表示检验输入的数据是否和输出的结果匹配,如果不匹配会在UT环节报错。


    业务代码从逻辑上可以被分为两个部分



    1. Give

    2. When


    Given可以是上下文的变量也可以是API调用


    When表示执行业务的代码块


    Blank diagram (23).png
    如果把UT的Then引入到业务的函数里,就可以实现运行时执行UT的效果了😁。


    将UT代码引入到业务函数中,思路是首先把UT按照Given、When、Then三部分拆分,把Given和Then部分独立出去,独立的部分通过代码插桩的方式在UT和业务代码中复用。


    Blank diagram (24).png


    不过到这里遗留了几个问题,暂时还没有太好的思路🧐



    1. 异步回调 - 业务代码或者UT检测逻辑只能执行一个

    2. 业务方法名的修改 - 使用Swift Macro插桩方式等同新增加一个全新的方法,所以运行时执行UT需要更改业务逻辑

    3. UT代码被执行多次 - 上面的图可以看出来,新的UT代码被同时插到旧的UT和业务代码中,所以执行UT时会UT逻辑被执行了两次


    代码插桩


    我们项目基于Swift,且插桩需要有判别逻辑,基于此选用AST的方式在编译期修改FunctionDecliation,把代码通过字符串的形式插到function body的合适位置。正巧2023 WWDC苹果发布了Swift Macro,其中的@Attached(Peer)正好可以满足对FunctionDecliation可编程修改,通过实现自动以@Attached(Peer)以及增加DEBUG宏(Swift Macro不能读取工程配置信息)可以改变UT的流程。真想说Swift太香了。


    最终


    完整的项目的整体架构大致如下,主要分了三部分。



    1. Hubble - 主要职责是提供基础协议、日志、预警和一些协助分析问题的工具集

    2. HubbleCoordinator - 主要职责是作为业务和组件交互的中间层,业务相关的逻辑都放在这里,隔离业务和Hubble

    3. 业务 - 仅处理Hubble初始化工作,对业务代码没有任何侵入
      AR


    作者:tom猪
    来源:juejin.cn/post/7274140856034099252
    收起阅读 »

    谷歌是如何写技术文档的

    Google软件工程文化的一个关键要素是使用设计文档来定义软件设计。这些相对非正式的文件由软件系统或应用程序的主要作者在开始编码项目之前创建。设计文档记录了高层次的实现策略和关键设计决策,重点强调在这些决策过程中考虑到的权衡。 作为软件工程师,我们的任务不仅仅...
    继续阅读 »

    Google软件工程文化的一个关键要素是使用设计文档来定义软件设计。这些相对非正式的文件由软件系统或应用程序的主要作者在开始编码项目之前创建。设计文档记录了高层次的实现策略和关键设计决策,重点强调在这些决策过程中考虑到的权衡。


    作为软件工程师,我们的任务不仅仅是生成代码,而更多地是解决问题。像设计文档这样的非结构化文本可能是项目生命周期早期解决问题更好的工具,因为它可能更简洁易懂,并且以比代码更高层次的方式传达问题和解决方案。


    除了原始软件设计文件外,设计文档还在以下方面发挥着作用:


    在进行变更时及早识别出设计问题仍然较便宜。


    在组织内达成对某个设计方案的共识。


    确保考虑到跨领域关注点。


    将资深工程师们掌握知识扩展到整个组织中去。


    形成围绕设计决策建立起来的组织记忆基础。


    作为技术人员投资组合中一份摘要性产物存在于其中。


    设计文档的构成


    设计文档是非正式的文件,因此其内容没有严格的指导方针。第一条规则是:以对特定项目最有意义的形式编写。


    话虽如此,事实证明,某种结构已经被证明非常有用。


    上下文和范围


    本节为读者提供了新系统构建的大致概述以及实际正在构建的内容。这不是一份需求文档。保持简洁!目标是让读者迅速了解情况,但可以假设有一些先前的知识,并且可以链接到详细信息。本节应完全专注于客观背景事实。


    目标和非目标


    列出系统目标的简短项目列表,有时更重要的是列出非目标。请注意,非目标并不是否定性的目标,比如“系统不应崩溃”,而是明确选择不作为目标而合理可能成为目标的事项。一个很好的例子就是“ACID兼容性”;在设计数据库时,您肯定想知道是否将其作为一个目标或非目标。如果它是一个非目标,则仍然可以选择提供该功能的解决方案,前提是它不会引入阻碍实现这些目标的权衡考虑。


    实际设计


    这一部分应该以概述开始,然后进入细节。


    image.png


    设计文档是记录你在软件设计中所做的权衡的地方。专注于这些权衡,以产生具有长期价值的有用文档。也就是说,在给定上下文(事实)、目标和非目标(需求)的情况下,设计文档是提出解决方案并展示为什么特定解决方案最能满足这些目标的地方。


    撰写文件而不是使用更正式的媒介之一的原因在于提供灵活性,以适当方式表达手头问题集。因此,并没有明确指导如何描述设计。


    话虽如此,已经出现了一些最佳实践和重复主题,在大多数设计文档中都很合理:


    系统上下文图


    在许多文档中,系统上下文图非常有用。这样的图表将系统显示为更大的技术环境的一部分,并允许读者根据他们已经熟悉的环境来理解新设计。


    image.png
    一个系统上下文图的示例。


    APIs


    如果设计的系统暴露出一个API,那么草拟出该API通常是个好主意。然而,在大多数情况下,应该抵制将正式接口或数据定义复制粘贴到文档中的诱惑,因为这些定义通常冗长、包含不必要的细节,并且很快就会过时。相反,重点关注与设计及其权衡相关的部分。


    数据存储


    存储数据的系统可能需要讨论如何以及以什么样的形式进行存储。与对API的建议类似,出于同样的原因,应避免完全复制粘贴模式定义。而是专注于与设计及其权衡相关的部分。


    代码和伪代码


    设计文档很少包含代码或伪代码,除非描述了新颖的算法。在适当的情况下,可以链接到展示设计可实现性的原型。


    约束程度


    影响软件设计和设计文档形状的主要因素之一是解决方案空间的约束程度。


    在极端情况下,有一个“全新软件项目”,我们只知道目标,解决方案可以是任何最合理的选择。这样的文档可能涉及范围广泛,但也需要快速定义一组规则,以便缩小到可管理的解决方案集。


    另一种情况是系统中可能存在非常明确定义的解决方案,但如何将它们结合起来实现目标并不明显。这可能是一个难以更改且未设计为满足您期望功能需求的遗留系统,或者是需要在主机编程语言约束下运行的库设计。


    在这种情况下,您可能能够相对容易地列举出所有可以做到的事情,但需要创造性地将这些事物组合起来实现目标。可能会有多个解决方案,并且没有一个真正很好,在识别了所有权衡后该文档应专注于选择最佳方式。


    考虑的替代方案


    本节列出了其他可能达到类似结果的设计方案。重点应放在每个设计方案所做的权衡以及这些权衡如何导致选择文档主题中所述设计的决策上。


    尽管对于最终未被选中的解决方案可以简洁地进行描述,但是这一部分非常重要,因为它明确展示了根据项目目标而选择该解决方案为最佳选项,并且还说明了其他解决方案引入了不太理想的权衡,读者可能会对此产生疑问。


    交叉关注点


    这是您的组织可以确保始终考虑到安全、隐私和可观察性等特定的交叉关注点的地方。这些通常是相对简短的部分,解释设计如何影响相关问题以及如何解决这些问题。团队应该在他们自己的情况下标准化这些关注点。


    由于其重要性,Google项目需要有专门的隐私设计文档,并且还有专门针对隐私和安全进行Review。尽管Review只要求在项目启动之前完成,但最佳实践是尽早与隐私和安全团队合作,以确保从一开始就将其纳入设计中。如果针对这些主题有专门文档,则中央设计文档当然可以引用它们而不详述。


    设计文档的长度


    设计文档应该足够详细,但又要短到忙碌的人实际上能读完。对于较大的项目来说,最佳页数似乎在10-20页左右。如果超过这个范围,可能需要将问题拆分成更易管理的子问题。还应注意到,完全可以编写一个1-3页的“迷你设计文档”。这对于敏捷项目中的增量改进或子任务尤其有帮助 - 你仍然按照长篇文档一样进行所有步骤,只是保持简洁,并专注于有限的问题集合。


    何时不需要编写设计文档


    编写设计文档是一种额外的工作量。是否要编写设计文档的决策取决于核心权衡,即组织共识在设计、文档、高级Review等方面的好处是否超过了创建文档所需的额外工作量。这个决策的核心在于解决设计问题是否模糊——因为问题复杂性或解决方案复杂性,或者两者都有。如果不模糊,则通过撰写文档来进行流程可能没有太大价值。


    一个明确的指标表明可能不需要文档是那些实际上只是实施手册而非设计文档。如果一个文件基本上说“这就是我们将如何实现它”,而没有涉及权衡、替代方案和解释决策(或者解决方案显然意味着没有任何权衡),那么直接编写程序可能会更好。


    最后,创建和Review设计文档所需的开销可能与原型制作和快速迭代不兼容。然而,大多数软件项目确实存在一系列已知问题。遵循敏捷方法论并不能成为对真正已知问题找到正确解决方案时间投入不足的借口。此外,原型制作本身可以是设计文档创建的一部分。“我尝试过,它有效”是选择一个设计方案的最佳论据之一。


    设计文档的生命周期


    设计文档的生命周期包括以下步骤:


    创建和快速迭代
    Review(可能需要多轮)
    实施和迭代
    维护和学习


    创作和快速迭代


    你撰写文档。有时与一组合著者共同完成。


    这个阶段很快进入了一个快速迭代的时间,文档会与那些对问题领域最了解的同事(通常是同一个团队的人)分享,并通过他们提出的澄清问题和建议来推动文档达到第一个相对稳定版本。


    虽然你肯定会找到一些工程师甚至团队更喜欢使用版本控制和代码Review工具来创建文档,但在谷歌,绝大多数设计文档都是在Google Docs中创建并广泛使用其协作功能。


    Review


    在Review阶段,设计文档会与比原始作者和紧密合作者更广泛的受众分享。Review可以增加很多价值,但也是一个危险的开销陷阱,所以要明智地对待。


    Review可以采取多种形式:较轻量级的版本是将文档发送给(更广泛的)团队列表,让大家有机会看一下。然后主要通过文档中的评论线程进行讨论。在Review方面较重型的方式是正式的设计评审会议,在这些会议上作者通常通过专门制作的演示文稿向经验丰富、资深工程师们展示该文档内容。谷歌公司许多团队都定期安排了此类会议,并邀请工程师参加审核。自然而然地等待这样的会议可能会显著减慢开发过程。工程师可以通过直接寻求最关键反馈并不阻碍整体审核进度来缓解这个问题。


    当谷歌还是一家较小的公司时,通常会将设计发送到一个中央邮件列表,高级工程师会在自己的闲暇时间进行Review。这可能是处理公司事务的好方法。其中一个好处是确立了相对统一的软件设计文化。但随着公司规模扩大到更庞大的工程团队,维持集中式方法变得不可行。


    此类Review所添加的主要价值在于它们为组织的集体经验提供了融入设计的机会。最重要的是,在Review阶段可以确保考虑到横切关注点(如可观察性、安全性和隐私)等方面。Review的主要价值并非问题被发现本身,而是这些问题相对早期地在开发生命周期内被发现,并且修改仍然相对廉价。


    实施和迭代


    当事情进展到足够程度,有信心进一步Review不需要对设计进行重大更改时,就是开始实施的时候了。随着计划与现实的碰撞,不可避免地会出现缺陷、未解决的需求或者被证明错误的猜测,并且需要修改设计。强烈建议在这种情况下更新设计文档。作为一个经验法则:如果设计系统尚未发布,则绝对要更新文档。在实践中,我们人类很擅长忘记更新文件,并且由于其他实际原因,变更通常被隔离到新文件中。这最终导致了一个类似于美国宪法带有一堆修正案而不是一份连贯文档的状态。从原始文档链接到这些修正案可以极大地帮助那些试图通过设计文档考古学来理解目标系统的可怜未来维护程序员们。


    维护和学习


    当谷歌工程师面对一个之前没有接触过的系统时,他们经常会问:“设计文档在哪里?”虽然设计文档(像所有文档一样)随着时间推移往往与现实脱节,但它们通常是了解系统创建背后思考方式最容易入手的途径。


    作为作者,请你给自己一个方便,并在一两年后重新阅读你自己的设计文档。你做得对了什么?你做错了什么?如果今天要做出不同决策,你会怎么选择?回答这些问题是作为一名工程师进步并改善软件设计技能的好方法。


    结论


    设计文档是在软件项目中解决最困难问题时获得清晰度和达成共识的好方法。它们可以节省金钱,因为避免了陷入编码死胡同而无法实现项目目标,并且可以通过前期调查来避免这种情况;但同时也需要花费时间和金钱进行创建和Review。所以,在选择项目时要明智!


    在考虑撰写设计文档时,请思考以下几点:


    您是否对正确的软件设计感到不确定,是否值得花费前期时间来获得确定性?


    相关地,是否有必要让资深工程师参与其中,即使他们可能无法Review每个代码更改,在设计方面能提供帮助吗?


    软件设计是否模糊甚至具有争议性,以至于在组织中达成共识将是有价值的?


    我的团队是否有时会忘记在设计中考虑隐私、安全、日志记录或其他横切关注点?


    组织中对遗留系统的高层次洞察力提供文档存在强烈需求吗?


    如果您对以上3个或更多问题回答“是”,那么撰写一个设计文档很可能是开始下一个软件项目的好方法。


    Reference


    http://www.industrialempathy.com/posts/desig…


    作者:dooocs
    来源:juejin.cn/post/7272730352710418447
    收起阅读 »

    拒绝代码PUA,优雅地迭代业务代码

    最初的美好 没有历史包袱,就没有压力,就是美好的。 假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。 ...
    继续阅读 »

    最初的美好


    没有历史包袱,就没有压力,就是美好的。


    假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。


    Ugly1.gif


    这样的需求开发起来很简单:



    • 数据实体


    data class Car(
    var shell: Shell? = null,
    var engine: Engine? = null,
    var wheel: Wheel? = null,
    ) : Serializable {
    override fun toString(): String {
    return "Car: Shell(${shell}), Engine(${engine}), Wheel(${wheel})"
    }
    }

    data class Shell(
    ...
    ) : Serializable

    data class Engine(
    ...
    ) : Serializable

    data class Wheel(
    ...
    ) : Serializable


    • 零件车间(以车架为例)


    class ShellFactoryActivity : AppCompatActivity() {
    private lateinit var btn: Button
    private lateinit var back: Button
    private lateinit var status: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_shell_factory)
    val car = intent.getSerializableExtra("car") as Car
    status = findViewById(R.id.status)
    btn = findViewById(R.id.btn)
    btn.setOnClickListener {
    car.shell = Shell(
    id = 1,
    name = "比亚迪车架",
    type = 1
    )
    status.text = car.toString()
    }
    back = findViewById(R.id.back)
    back.setOnClickListener {
    setResult(RESULT_OK, intent.apply {
    putExtra("car", car)
    })
    finish()
    }
    }
    }


    class EngineFactoryActivity : AppCompatActivity() {
    // 和安装车架流程一样
    }

    class WheelFactoryActivity : AppCompatActivity() {
    // 和安装车架流程一样
    }


    • 提车车间


    class MainActivity : AppCompatActivity() {
    private var car: Car? = null
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    car = Car()
    refreshStatus()
    findViewById<Button>(R.id.shell).setOnClickListener {
    val it = Intent(this, ShellFactoryActivity::class.java)
    it.putExtra("car", car)
    startActivityForResult(it, REQUEST_SHELL)
    }
    findViewById<Button>(R.id.engine).setOnClickListener {
    val it = Intent(this, EngineFactoryActivity::class.java)
    it.putExtra("car", car)
    startActivityForResult(it, REQUEST_ENGINE)
    }
    findViewById<Button>(R.id.wheel).setOnClickListener {
    val it = Intent(this, WheelFactoryActivity::class.java)
    it.putExtra("car", car)
    startActivityForResult(it, REQUEST_WHEEL)
    }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (resultCode != RESULT_OK) return
    when (requestCode) {
    REQUEST_SHELL -> {
    Log.i(TAG, "安装车架完成")
    car = data?.getSerializableExtra("car") as Car
    }
    REQUEST_ENGINE -> {
    Log.i(TAG, "安装发动机完成")
    car = data?.getSerializableExtra("car") as Car
    }
    REQUEST_WHEEL -> {
    Log.i(TAG, "安装车轮完成")
    car = data?.getSerializableExtra("car") as Car
    }
    }
    refreshStatus()
    }

    private fun refreshStatus() {
    findViewById<TextView>(R.id.status).text = car?.toString()
    findViewById<Button>(R.id.save).run {
    isEnabled = car?.shell != null && car?.engine != null && car?.wheel != null
    setOnClickListener {
    Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
    }
    }
    }

    companion object {
    private const val TAG = "MainActivity"
    const val REQUEST_SHELL = 1
    const val REQUEST_ENGINE = 2
    const val REQUEST_WHEEL = 3
    }
    }

    即使是初学者也能看出来,业务实现起来很简单,通过ActivitystartActivityForResult就能跳转到相应的零件车间,安装好零件回到提车车间就完事了。


    开始迭代


    往往业务的第一个版本就是这么简单,感觉也没什么好重构的。


    但是业务难免会进行迭代。比如业务迭代到1.1版本:客户想要给汽车装上行车电脑,而安装行车电脑不需要跳转到另一个车间,而是在提车车间操作,但是需要很长的时间。


    Ugly2.gif


    看起来也简单,新增一个Computer实体类和ComputerFactoryHelper


    object ComputerFactoryHelper {
    fun provideComputer(block: Computer.() -> Unit) {
    Thread.sleep(5_000)
    block(Computer())
    }
    }

    data class Computer(
    val id: Int = 1,
    val name: String = "行车电脑",
    val cpu: String = "麒麟90000"
    ) : Serializable {
    override fun toString(): String {
    return "$name-$cpu"
    }
    }

    再在提车车间新增按钮和逻辑代码:


    findViewById<Button>(R.id.computer).setOnClickListener {
    object : Thread() {
    override fun run() {
    ComputerFactoryHelper.provideComputer {
    car?.computer = this
    runOnUiThread { refreshStatus() }
    }
    }
    }.start()

    }

    目前看起来也没啥难的,那是因为我们模拟的业务场景足够简单,但是相信很多实际项目的屎山代码,就是通过这样的业务迭代,一点一点地堆积而成的。


    从迭代到崩溃


    咱们来采访一下最近被一个小小的业务迭代需求搞崩溃的Android开发——小王。



    记者:小王你好,听说最近你Emo了,甚至多次萌生了就地辞职的念头?


    小王:最近AI不是很火吗,产品给我提了一个需求,在上传音乐时可以选择在后端生成一个AI视频,然后一起上传。


    记者:哦?这不是一个小需求吗?


    小王:但是我打开目前上传业务的代码就傻了啊!就说Activity吧,有:BasePublishActivity,BasePublishFinallyActivity,SinglePublishMusicActivity,MultiPublishMusicActivity,PublishFinallyActivity,PublishCutMusicFinallyActivity, Publish(好多好多)FinallyActivity... 当然,这只是冰山一角。再说上传流程。如果只上传一首音乐,需要先调一个接口/sts拿到一个Oss Token,再调用第三方的Oss库上传文件,拿到一个url,然后再把这个url和其他的信息(标题、标签等)组成一个HashMap,再调用一个接口/save提交到后端,相当于调3个接口... 如果要批量上传N个音乐,就要调3 * N个接口,如果还要给每个音乐配M个图片,就要调3 * N+3 * N * M个接口... 如果上传一个音乐配一个本地视频,就要调3 * 2 * N个接口,并且,上传视频流程还不一样的是,需要在调用/save接口之后再调用第三方Oss上传视频文件...再说数据类。上面提到上传过程中需要添加图片、视频、活动类型啥的,代码里封装了一个EditInfo类足足有30个属性!,由于是Java代码并且实现了Parcelable接口,光一个Data类就有400多行!你以为这就完了?EditInfo需要在上传时转成PublishInfo类,PublishInfo还可以转成PublishDraft,PublishDraft可以保存到数据库中,从数据库中可以读取PublishDraft然后转成EditInfo再重新编辑...


    记者:(感觉小王精神状态有点问题,于是掐掉了直播画面)



    相信小王的这种情况,很多业务开发同学都经历过吧。回头再看一下前面的造车业务,其实和小王的上传业务一样,就是一开始很简单,迭代个7、8个版本就开始陷入一种困境:即使迭代需求再小,开发起来都很困难。


    优雅地迭代业务代码?


    假如咱们想要优雅地迭代业务代码,应该怎么做呢?


    小王遇到的这座屎山,咱们现在就不要去碰了,先就从前面提到的造车业务开始吧。


    很多同学会想到重构,俺也一样。接下来,我就要讨论一下如何优雅安全地重构既有业务代码。



    先抛出一个观点:对于程序员来说,想要保持“优雅”,最重要的品质就是抽象。



    ❓ 这时可能有同学就要反驳我了:过早的抽象没有必要。


    ❗ 别急,我现在要说的抽象,并不是代码层面的抽象,而是对业务的抽象,乃至对技术思维的抽象


    什么是代码层面的抽象?比如刚刚的Shell/Engine/WheelFactoryActivity,其实是可以抽象为BaseFactoryActivity,然后通过实现其中的零件类型就行了。但我不会建议你这么做,为啥?看看小王刚才的疯言疯语就明白了。各个XxxFactoryActivity看着差不多,但在实际项目中很可能会开枝散叶,各自迭代出不同的业务细节。到那时,项目里就是各种BaseXxxActivityXxxV1ActivityXxxV2Activity...


    那什么又是业务的抽象?直接上代码:


    interface CarFactory {
    val factory: suspend Car.() -> Car
    }

    造车业务,无论在哪个环节,都是在Car上装配零件(或者任何出其不意的操作),然后产生一个新的Car;另外,这个环节无论是跳转到零件车间安装零件,还是在提车车间里安装行车电脑,都是耗时操作,所以需要加suspend关键词。


    ❓ 这时可能有同学说:害!你这和BaseFactoryActivity有啥区别,不就是把抽象类换成接口了吗?


    ❗ 别急,我并没有要让XxxFactoryActivity去继承CarFactory啊,想想小王吧,这个XxxFactoryActivity就相当于他的同事在两年前写的代码,小王肯定打死都不会想去修改这里面的代码的。


    Computer是新业务,我们只改它。首先我们根据这个接口把ComputerFactoryHelper改一下:


    object ComputerFactoryHelper : CarFactory {
    private suspend fun provideComputer(block: Computer.() -> Unit) {
    delay(5_000)
    block(Computer())
    }

    override val factory: suspend Car.() -> Car = {
    provideComputer {
    computer = this
    }
    this
    }
    }

    那么,在提车车间就可以这样改:


    private var computerFactory: CarFactory = ComputerFactoryHelper
    findViewById<Button>(R.id.computer).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    computerFactory.factory.invoke(car)
    refreshStatus()
    }
    }

    ❓ 那么XxxFactoryActivity相关的流程又应该怎么重构呢?


    Emo时间


    我先反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


    我再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


    我再再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


    甚至,很多人即使学了ComposeFlutter,仍然对Activity心心念念。



    当你在一千个日日夜夜里,重复地写着XxxActivity,写onCreate/onResume/onDestroy,写startActivityForResult/onActivityResult时,当你每次想要换工作,打开面经背诵Activity的启动模式,生命周期,AMS原理时,可曾对Activity有过厌倦,可曾思考过编程的意义?


    你也曾努力查阅Activity的源码,学习MVP/MVVM/MVI架构,试图让你的Activity保持清洁,但无论你怎么努力,却始终活在Activity的阴影之下。


    你有没有想过,咱们正在被Activity PUA



    说实话,作为一名INFP,本人不是很适合做程序员。相比技术栈的丰富和技术原理的深入,我更看重的是写代码的感受。如果写代码都能被PUA,那我还怎么愉快的写代码?


    当我Emo了很久之后,我意识到了,我一直在被代码PUA,不光是同事的代码,也有自己的代码,甚至有Android框架,以及外国大佬不断推出的各种新技术新框架。



    对对对!你们都没有问题,是我太菜了555555555



    优雅转身


    Emo过后,还是得回到残酷的职场啊!但是我们要优雅地转身回来!


    ❓ 刚才不是说要处理XxxFactoryActivity相关业务吗?


    ❗ 这时我就要提到另外一种抽象:技术思维的抽象


    Activity?F*ck off!


    Activity的跳转返回啥的,也无非就是一次耗时操作嘛,咱们也应该将它抽象为CarFactory,就是这个东东:


    interface CarFactory {
    val factory: suspend Car.() -> Car
    }

    基于这个信念,我从记忆中想到这么一个东东:ActivityResultLauncher


    说实话,我以前都没用过这玩意儿,但是我这时好像抓到了救命稻草。


    随便搜了个教程并谢谢他,参考这篇博客,我们可以把startActivityForResultonActivityResult这套流程,封装成一次异步调用。


    open class BaseActivity : AppCompatActivity() {
    private lateinit var startActivityForResultLauncher: StartActivityForResultLauncher

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    startActivityForResultLauncher = StartActivityForResultLauncher(this)
    }

    fun startActivityForResult(
    intent: Intent,
    callback: (resultCode: Int, data: Intent?) -> Unit
    )
    {
    startActivityForResultLauncher.launch(intent) {
    callback.invoke(it.resultCode, it.data)
    }
    }
    }

    MainActivity继承BaseActivity,就可以绕过Activity了,后面的事情就简单了。只要咱们了解过协程,就能轻易想到异步转同步这一普通操作:suspendCoroutine


    于是,我们就可以在不修改XxxFactoryActivity的情况下,写出基于CarFactory的代码了。还是以车架车间为例:


    class ShellFactoryHelper(private val activity: BaseActivity) : CarFactory {

    override val factory: suspend Car.() -> Car = {
    suspendCoroutine { continuation ->
    val it = Intent(activity, ShellFactoryActivity::class.java)
    it.putExtra("car", this)
    activity.startActivityForResult(it) { resultCode, data ->
    (data?.getSerializableExtra("car") as? Car)?.let {
    Log.i(TAG, "安装车壳完成")
    shell = it.shell
    continuation.resumeWith(Result.success(this))
    }
    }
    }
    }
    }

    然后在提车车间,和Computer业务同样的使用方式:


    private var shellFactory: CarFactory = ShellFactoryHelper(this)
    findViewById<Button>(R.id.shell).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    shellFactory.factory.invoke(car)
    refreshStatus()
    }
    }

    最终,在我们的提车车间,依赖的就是一些CarFactory,所有的业务操作都是抽象的。到达这个阶段,相信大家都有了自己的一些想法了(比如维护一个carFactoryList,用Hilt管理CarFactory依赖,泛型封装等),想要继续怎么重构/维护,就全看自己的实际情况了。


    class MainActivity : BaseActivity() {
    private var car: Car = Car()
    private var computerFactory: CarFactory = ComputerFactoryHelper
    private var engineFactory: CarFactory = EngineFactoryHelper(this)
    private var shellFactory: CarFactory = ShellFactoryHelper(this)
    private var wheelFactory: CarFactory = WheelFactoryHelper(this)

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    refreshStatus()
    findViewById<Button>(R.id.shell).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    shellFactory.factory.invoke(car)
    refreshStatus()
    }
    }
    findViewById<Button>(R.id.engine).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    engineFactory.factory.invoke(car)
    refreshStatus()
    }
    }
    findViewById<Button>(R.id.wheel).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    wheelFactory.factory.invoke(car)
    refreshStatus()
    }
    }
    findViewById<Button>(R.id.computer).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    Toast.makeText(this@MainActivity, "稍等一会儿", Toast.LENGTH_LONG).show()
    computerFactory.factory.invoke(car)
    Toast.makeText(this@MainActivity, "装好了!", Toast.LENGTH_LONG).show()
    refreshStatus()
    }
    }
    }

    private fun refreshStatus() {
    findViewById<TextView>(R.id.status).text = car.toString()
    findViewById<Button>(R.id.save).run {
    isEnabled = car.shell != null && car.engine != null && car.wheel != null && car.computer != null
    setOnClickListener {
    Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
    }
    }
    }
    }

    总结



    • 抽象是程序员保持优雅的最重要能力。

    • 抽象不应局限在代码层面,而是要上升到业务,乃至技术思维上。

    • 有意识地对代码PUA说:No!

    • 学习新技术时,不应只学会调用,也不应迷失在技术原理上,更重要的是花哨的技术和朴实的编程思想之间的化学反应。


    作者:blackfrog
    来源:juejin.cn/post/7274084216286036004
    收起阅读 »

    Xcode15Beta填坑-修复YYLabel的Crash问题

    iOS
    前言 趁着版本空隙,升级到了Xcode15-Beta2本想提前体验下iOS17。本以为这次升级Xcode能直接运行应该没什么大问题,没曾想到一运行后程序直接Crash了,Crash是在YYLabel下的YYAsyncLayer类里面。众所周知,YYLabel是...
    继续阅读 »

    前言


    趁着版本空隙,升级到了Xcode15-Beta2本想提前体验下iOS17。本以为这次升级Xcode能直接运行应该没什么大问题,没曾想到一运行后程序直接Crash了,Crash是在YYLabel下的YYAsyncLayer类里面。众所周知,YYLabel是由远古大神ibireme开发的YYKit下属的组件。已经多年没有适配了,但是依然老当益壮,只有部份由于Api变更导致的问题需要简单维护即可。以下就是此次问题定位与修复的全过程。


    Crash定位


    此次升级后编译我司项目,直接Crash,Crash日志如下。




    Crash是在YYTextAsyncLayer类下面的第193行代码如下:


    UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.opaque, self.contentsScale);


    其实第一眼看代码崩溃提示就很明显了,这次Xcode15在UIGraphicsBeginImageContextWithOptions下面加了断言,如果传入的size width 或者 height其中一个为0,会直接return 返回断言。并且提示我们升级Api为UIGraphicsImageRenderer可以解决此问题。


    本着探究的精神,我重新撤回用Xcode14.3.1编译,看为什么不会崩溃,结果其实也会报Invalid size警告但是不会崩溃,警告如下。




    解决方案


    我们使用UIGraphicsImageRenderer替代老旧的UIGraphicsBeginImageContextWithOptions(其实早已标记为过时),实测即使size为 zero,UIGraphicsImageRenderer在Xcode15下依然会渲染出一个zero size的Image,但是这毫无意义,所以我们简单判断一下,如果是非法的size我们直接retrun,代码如下:


    从193行开始一直替换到self.contents = xxx。为止,即可解决此次问题。


    if (self.bounds.size.width < 1 || self.bounds.size.height < 1) {

    CGImageRef image = (__bridge_retained CGImageRef)(self.contents);

    self.contents = nil;

    if (image) {

    CFRelease(image);

    }

    return;

    }

    UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:self.bounds.size];

    UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {

    if (self.opaque) {

    if (!self.backgroundColor || CGColorGetAlpha(self.backgroundColor) < 1) {

    CGContextSetFillColorWithColor(context.CGContext, [UIColor whiteColor].CGColor);

    [context fillRect:self.bounds];

    }

    if (self.backgroundColor) {

    CGContextSetFillColorWithColor(context.CGContext, self.backgroundColor);

    [context fillRect:self.bounds];

    }

    }

    task.display(context.CGContext, self.bounds.size, ^{return NO;});

    }];

    self.contents = (__bridge id)(image.CGImage);


    结尾


    以上就是Xcode15修复UIGraphicsBeginImageContextWithOptions由于加了断言导致的Crash问题。我也强烈建议各位有时间检查项目其他代码直接升级成UIGraphicsImageRenderer的方案。如果确实没时间,要加上如下判断,防止Crash。由于我是在Debug上必崩,如果是断言问题Release不一定会有事,但是还是建议大家修改一下。


    if (self.size.width < 1 || self.size.height < 1) {

    return nil;

    }

    作者:jerryfans
    链接:https://juejin.cn/post/7250288616534261797
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    刚咬了一口馒头,服务器突然炸了!

    首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。 其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket...
    继续阅读 »

    首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。
    其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket管理器。


    看着群里一群“小可爱”疯狂乱叫,我被吵的头都炸了,赶紧尝试定位问题。

    1. 查看是否存在Jenkins发版 -> 无
    2. 查看最新提交记录 -> 最后一次提交是下午,到晚上这段时间系统一直是稳定的
    3. 查看服务器资源,htop后,发现三台相关的云服务器资源都出现闲置状态
    4. 查看PolarDB后,既MySQL,连接池正常、吞吐量和锁正常
    5. 查看Redis,资源正常,无异常key
    6. 查看前端控制台,出现一些报错,但是这些报错经常会变化
    7. 查看前端测试环境、后端测试环境,程序全部正常
    8. 重启前端服务、后端服务、NGINX服务,好像没用,过了5分钟后,咦,好像可以访问了

    就在我们组里的“小可爱”通知系统恢复正常后,20分钟不到,再一次处于无法打开的状态,沃焯!!!
    完蛋了,找不出问题在哪里了,实在想不通问题究竟出在哪里。


    我不服啊,我不理解啊!


    咦,Nginx?对呀,我瞅瞅访问日志,于是我浏览了一下access.log,看起来貌似没什么问题,不过存在大量不同浏览器的访问记录,刷的非常快。


    再瞅瞅error.log,好像哪里不太对

    2023/04/20 23:15:35 [alert] 3348512#3348512: 768 worker_connections are not enough
    2023/04/20 23:33:33 [alert] 3349854#3349854: *3492 open socket #735 left in connection 1013

    这是什么?貌似是连接池问题,赶紧打开nginx.conf看一下配置

    events {
    worker_connections 666;
    # multi_accept on;
    }

    ???


    运维小可爱,你特么在跟我开玩笑?虽然我们这个系统是给B端用的,还是我们自己组的不到100人,但是这连接池给的也太少了吧!


    另外,前端为什么会开到 1000 个 WS 连接呢?后端为什么没有释放掉FD呢?


    询问后,才知道,前端的Socket管理器,会在连接超时或者其它异常情况下,重新开启一个WS连接。


    后端的心跳配置给了300秒

    Constant::OPTION_OPEN_WEBSOCKET_PROTOCOL => true, // websocket 协议
    Constant::OPTION_HEARTBEAT_CHECK_INTERVAL => 150,
    Constant::OPTION_HEARTBEAT_IDLE_TIME => 300,

    此时修改nginx.conf的配置,直接拉满!!!

    worker_connections 655350;

    重启Nginx,哇绰,好像可以访问了,但是每当解决一个问题,总会产生新的问题。


    此时error.log中出现了新的报错:

    2023/04/20 23:23:41 [crit] 3349767#3349767: accept4() failed (24: Too many open files)

    这种就不怕了,貌似和Linux的文件句柄限制有关系,印象中是1024个。
    至少有方向去排查,不是吗?而且这已经算是常规问题了,我只需小小的百度一下,哼哼~


    拉满拉满!!

    worker_rlimit_nofile 65535;

    此时再次重启Nginx服务,系统恢复稳定,查看当前连接数:

    netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
    # 打印结果
    TIME_WAIT 1175

    FIN_WAIT1 52

    SYN_RECV 1

    FIN_WAIT2 9

    ESTABLISHED 2033

    经过多次查看,发现TIME_WAITESTABLISHED都在不断减少,最后完全降下来。


    本次问题排查结束,问题得到了解决,但是关于socket的连接池管理器,仍然需要优化,及时释放socket无用的连接。


    作者:兰陵笑笑生666
    链接:https://juejin.cn/post/7224314619865923621
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    99% 的 iOS 开发都不知道的 KVO 崩溃

    iOS
    背景 crash 监控发现有大量的新增崩溃,堆栈如下0 libsystem_platform.dylib __os_unfair_lock_corruption_abort() 1 libsystem_platform.dylib __os_unfair_lo...
    继续阅读 »

    背景


    crash 监控发现有大量的新增崩溃,堆栈如下

    0	libsystem_platform.dylib	__os_unfair_lock_corruption_abort()
    1 libsystem_platform.dylib __os_unfair_lock_lock_slow()
    2 Foundation __NSSetBoolValueAndNotify()

    分析堆栈


    __os_unfair_lock_corruption_abort


    log 翻译:lock 已损坏

    _os_unfair_lock_corruption_abort(os_ulock_value_t current)
    {
    __LIBPLATFORM_CLIENT_CRASH__(current, "os_unfair_lock is corrupt");
    }

    __os_unfair_lock_lock_slow


    在这个方法里面 __ulock_wait 返回 EOWNERDEAD 调用 corruption abort 方法。

    int ret = __ulock_wait(UL_UNFAIR_LOCK | ULF_NO_ERRNO | options,
    l, current, 0);
    if (unlikely(ret < 0)) {
    switch (-ret) {
    case EINTR:
    case EFAULT:
    continue;
    case EOWNERDEAD:
    _os_unfair_lock_corruption_abort(current);
    break;
    default:
    __LIBPLATFORM_INTERNAL_CRASH__(-ret, "ulock_wait failure");
    }
    }

    EOWNERDEAD 的定义


    #define EOWNERDEAD      105             /* Previous owner died */


    到这里猜测是 lock 的 owner 已经野指针了,继续向下看。


    __NSSetBoolValueAndNotify


    google 下这个方法是在 KVO 里面修改属性的时候调用,伪代码:

    int __NSSetBoolValueAndNotify(int arg0, int arg1, int arg2) {
    r31 = r31 - 0x90;
    var_30 = r24;
    stack[-56] = r23;
    var_20 = r22;
    stack[-40] = r21;
    var_10 = r20;
    stack[-24] = r19;
    saved_fp = r29;
    stack[-8] = r30;
    r20 = arg2;
    r21 = arg1;
    r19 = arg0;
    r0 = object_getClass(arg0);
    r0 = object_getIndexedIvars(r0); // 理清这个崩溃的关键方法,这里和汇编代码不一致,汇编代码的入参是 r0 + 0x20
    r23 = r0;
    os_unfair_recursive_lock_lock_with_options();
    CFDictionaryGetValue(*(r23 + 0x18), r21);
    r22 = _objc_msgSend$copyWithZone:();
    os_unfair_recursive_lock_unlock();
    if (*(int8_t *)(r23 + 0x28) != 0x0) {
    _objc_msgSend$willChangeValueForKey:();
    (class_getMethodImplementation(*r23, r21))(r19, r21, r20);
    _objc_msgSend$didChangeValueForKey:();
    }
    else {
    _objc_msgSend$_changeValueForKey:key:key:usingBlock:();
    }
    var_38 = **qword_9590e8;
    r0 = objc_release_x22();
    if (**qword_9590e8 != var_38) {
    r0 = __stack_chk_fail();
    }
    return r0;
    }

    os_unfair_recursive_lock_lock_with_options


    崩溃调用栈中间还有这一层的内联调用 os_unfair_recursive_lock_lock_with_options。这里的 lock owner 有个比较赋值的操作,如果 oul_value 等于 OS_LOCK_NO_OWNER 则赋值 self 然后 return。崩溃时这里继续向下执行了,那这里的 oul_value 的取值只能是 lock->oul_value。到这里猜测崩溃的原因是 lock->oul_value 野指针了。

    void
    os_unfair_recursive_lock_lock_with_options(os_unfair_recursive_lock_t lock,
    os_unfair_lock_options_t options)
    {
    os_lock_owner_t cur, self = _os_lock_owner_get_self();
    _os_unfair_lock_t l = (_os_unfair_lock_t)&lock->ourl_lock;

    if (likely(os_atomic_cmpxchgv2o(l, oul_value,
    OS_LOCK_NO_OWNER, self, &cur, acquire))) {
    return;
    }

    if (OS_ULOCK_OWNER(cur) == self) {
    lock->ourl_count++;
    return;
    }

    return _os_unfair_lock_lock_slow(l, self, options);
    }


    OS_ALWAYS_INLINE OS_CONST
    static inline os_lock_owner_t
    _os_lock_owner_get_self(void)
    {
    os_lock_owner_t self;
    self = (os_lock_owner_t)_os_tsd_get_direct(__TSD_MACH_THREAD_SELF);
    return self;
    }

    object_getIndexedIvars


    __NSSetBoolValueAndNotify 里面的获取 lock 的方法,这个函数非常关键。

    /** 
    * Returns a pointer to any extra bytes allocated with an instance given object.
    *
    * @param obj An Objective-C object.
    *
    * @return A pointer to any extra bytes allocated with \e obj. If \e obj was
    * not allocated with any extra bytes, then dereferencing the returned pointer is undefined.
    *
    * @note This function returns a pointer to any extra bytes allocated with the instance
    * (as specified by \c class_createInstance with extraBytes>0). This memory follows the
    * object's ordinary ivars, but may not be adjacent to the last ivar.
    * @note The returned pointer is guaranteed to be pointer-size aligned, even if the area following
    * the object's last ivar is less aligned than that. Alignment greater than pointer-size is never
    * guaranteed, even if the area following the object's last ivar is more aligned than that.
    * @note In a garbage-collected environment, the memory is scanned conservatively.
    /**
    * Returns a pointer immediately after the instance variables declared in an
    * object. This is a pointer to the storage specified with the extraBytes
    * parameter given when allocating an object.
    */
    void *object_getIndexedIvars(id obj)
    {
    uint8_t *base = (uint8_t *)obj;

    if (_objc_isTaggedPointerOrNil(obj)) return nil;

    if (!obj->isClass()) return base + obj->ISA()->alignedInstanceSize();

    Class cls = (Class)obj;
    if (!cls->isAnySwift()) return base + sizeof(objc_class);

    swift_class_t *swcls = (swift_class_t *)cls;
    return base - swcls->classAddressOffset + word_align(swcls->classSize);
    }

    上层调用 __NSSetBoolValueAndNotify 里面:


    r0 = object_getClass(arg0),arg0 是实例对象,r0 是类对象,因为这里是个 KVO 的调用,那正常情况下r0 是 NSKVONotifying_xxx。


    对于 KVO 类,object_getIndexedIvars 返回的地址是 (uint8_t *)obj + sizeof(objc_class)。根据函数的注释,这个地址指向创建类时附在类空间后 extraBytes 大小的一块内存。


    debug 调试


    object_getIndexedIvars


    __NSSetBoolValueAndNotify 下的调用



    object_getIndexedIvars 入参是 NSKVONotifying_KVObject,object_getClass 获取的是 KVO Class。


    objc_allocateClassPair


    动态创建 KVO 类的方法。

     thread #8, queue = 'com.apple.root.default-qos', stop reason = breakpoint 1.1
    * frame #0: 0x000000018143a088 libobjc.A.dylib`objc_allocateClassPair
    frame #1: 0x000000018259cd94 Foundation`_NSKVONotifyingCreateInfoWithOriginalClass + 152
    frame #2: 0x00000001825b8fd0 Foundation`_NSKeyValueContainerClassGetNotifyingInfo + 56
    frame #3: 0x000000018254b7dc Foundation`-[NSKeyValueUnnestedProperty _isaForAutonotifying] + 44
    frame #4: 0x000000018254b504 Foundation`-[NSKeyValueUnnestedProperty isaForAutonotifying] + 88
    frame #5: 0x000000018254b32c Foundation`-[NSObject(NSKeyValueObserverRegistration) _addObserver:forProperty:options:context:] + 404
    frame #6: 0x000000018254b054 Foundation`-[NSObject(NSKeyValueObserverRegistration) addObserver:forKeyPath:options:context:] + 136
    frame #7: 0x00000001040d1860 Test`__29-[ViewController viewDidLoad]_block_invoke(.block_descriptor=0x0000000282a55170) at ViewController.m:28:13
    frame #8: 0x00000001043d05a8 libdispatch.dylib`_dispatch_call_block_and_release + 32
    frame #9: 0x00000001043d205c libdispatch.dylib`_dispatch_client_callout + 20
    frame #10: 0x00000001043d4b94 libdispatch.dylib`_dispatch_queue_override_invoke + 1052
    frame #11: 0x00000001043e6478 libdispatch.dylib`_dispatch_root_queue_drain + 408
    frame #12: 0x00000001043e6e74 libdispatch.dylib`_dispatch_worker_thread2 + 196
    frame #13: 0x00000001d515fdbc libsystem_pthread.dylib`_pthread_wqthread + 228

    _NSKVONotifyingCreateInfoWithOriginalClass


    objc_allocateClassPair 的上层调用。 allocate 之前的 context w2 是个固定值 0x30,即创建 KVO Class 入参 extraBytes 的大小是 0x30

        0x18259cd78 <+124>: mov    x1, x21
    0x18259cd7c <+128>: mov x2, x22
    0x18259cd80 <+132>: bl 0x188097080
    0x18259cd84 <+136>: mov x0, x20
    0x18259cd88 <+140>: mov x1, x19
    0x18259cd8c <+144>: mov w2, #0x30
    0x18259cd90 <+148>: bl 0x1880961f0 // objc_allocateClassPair
    0x18259cd94 <+152>: cbz x0, 0x18259ce24 ; <+296>
    0x18259cd98 <+156>: mov x21, x0
    0x18259cd9c <+160>: bl 0x188096410 // objc_registerClassPair
    0x18259cda0 <+164>: mov x0, x19
    0x18259cda4 <+168>: bl 0x182b45f44 ; symbol stub for: free
    0x18259cda8 <+172>: mov x0, x21
    0x18259cdac <+176>: bl 0x1880967e0 // object_getIndexedIvars
    0x18259cdb0 <+180>: mov x19, x0
    0x18259cdb4 <+184>: stp x20, x21, [x0]

    _NSKVONotifyingCreateInfoWithOriginalClass+184 处将 x20 和 x21 写入 [x0],此时 x0 指向的是大小为 extraBytes 的内存,打印 x20 和 x21 的值


        x20 = 0x00000001117caa10  (void *)0x00000001117caa38: KVObject(向上回溯这个值取自 _NSKVONotifyingCreateInfoWithOriginalClass 的入参 x0)


        x21 NSKVONotifying_KVObject


    根据这里可以看出 object_getIndexedIvars 返回的地址,依次存储了 KVObject(origin Class) 和 NSKVONotifying_KVObject(KVO Class)。


    查看 _NSKVONotifyingCreateInfoWithOriginalClass 的伪代码,对 [x0] 有 5 次写入的操作,并且最终这个方法返回的是 x0 的地址。

    function __NSKVONotifyingCreateInfoWithOriginalClass {
    r31 = r31 - 0x50;
    stack[32] = r22;
    stack[40] = r21;
    stack[48] = r20;
    stack[56] = r19;
    stack[64] = r29;
    stack[72] = r30;
    r20 = r0;
    if (*(int8_t *)0x993e78 != 0x0) {
    os_unfair_lock_assert_owner(0x993e7c);
    }
    r0 = class_getName(r20);
    r22 = strlen(r0) + 0x10;
    r0 = malloc(r22);
    r19 = r0;
    strlcpy(r0, "NSKVONotifying_", r22);
    strlcat(r19, r21, r22);
    r0 = objc_allocateClassPair(r20, r19, 0x30);
    if (r0 != 0x0) {
    objc_registerClassPair(r0);
    free(r19);
    r0 = object_getIndexedIvars(r21);
    r19 = r0;
    *(int128_t *)r0 = r20; // 第一次写入 Class
    *(int128_t *)(r0 + 0x8) = r21; // 第二次写入 Class
    *(r19 + 0x10) = CFSetCreateMutable(0x0, 0x0, *qword_9592d8); // 第三次写入 CFSet
    *(int128_t *)(r19 + 0x18) = CFDictionaryCreateMutable(0x0, 0x0, 0x0, *qword_959598); // 第四次写入 CFDictionary
    *(int128_t *)(r19 + 0x20) = 0x0; // 第五次写入空值
    if (*qword_9fc560 != -0x1) {
    dispatch_once(0x9fc560, 0x8eaf98);
    }
    if (class_getMethodImplementation(*r19, @selector(willChangeValueForKey:)) != *qword_9fc568) {
    r8 = 0x1;
    }
    else {
    r0 = *r19;
    r0 = class_getMethodImplementation(r0, @selector(didChangeValueForKey:));
    r8 = *qword_9fc570;
    if (r0 != r8) {
    r8 = *qword_9fc570;
    if (CPU_FLAGS & NE) {
    r8 = 0x1;
    }
    }
    }
    *(int8_t *)(r19 + 0x28) = r8;
    _NSKVONotifyingSetMethodImplementation(r19, @selector(_isKVOA), 0x44fab4, 0x0);
    _NSKVONotifyingSetMethodImplementation(r19, @selector(dealloc), 0x44fabc, 0x0);
    _NSKVONotifyingSetMethodImplementation(r19, @selector(class), 0x44fd2c, 0x0);
    }
    else {
    if (*qword_9fc558 != -0x1) {
    dispatch_once(0x9fc558, 0x8eaf78);
    }
    if (os_log_type_enabled(*0x9fc550, 0x10) != 0x0) {
    _os_log_error_impl(0x0, *0x9fc550, 0x10, "KVO failed to allocate class pair for name %s, automatic key-value observing will not work for this class", &stack[0], 0xc);
    }
    free(r19);
    r19 = 0x0;
    }
    if (**qword_9590e8 == **qword_9590e8) {
    r0 = r19;
    }
    else {
    r0 = __stack_chk_fail();
    }
    return r0;
    }

    _NSKVONotifyingCreateInfoWithOriginalClass 的上层调用,入参是 [x19, #0x8],返回的参数写入 [x19, #0x28]

        0x1825b8fc0 <+40>: ldr    x0, [x19, #0x28]
    0x1825b8fc4 <+44>: b 0x1825b8fd4 ; <+60>
    0x1825b8fc8 <+48>: ldr x0, [x19, #0x8]
    -> 0x1825b8fcc <+52>: bl 0x18259ccfc ; _NSKVONotifyingCreateInfoWithOriginalClass
    0x1825b8fd0 <+56>: str x0, [x19, #0x28]
    0x1825b8fd4 <+60>: ldp x29, x30, [sp, #0x10]
    0x1825b8fd8 <+64>: ldp x20, x19, [sp], #0x20

    打印 x19 是一个 NSKeyValueContainerClass 类型的实例对象,这个对象类的 ivars layout

    ivars 0x99f3c0 __OBJC_$_INSTANCE_VARIABLES_NSKeyValueContainerClass
    entsize 32
    count 5
    offset 0x9e6048 _OBJC_IVAR_$_NSKeyValueContainerClass._originalClass 8
    name 0x90bd27 _originalClass
    type 0x929ae6 #
    alignment 3
    size 8
    offset 0x9e6050 _OBJC_IVAR_$_NSKeyValueContainerClass._cachedObservationInfoImplementation 16
    name 0x90bd36 _cachedObservationInfoImplementation
    type 0x92bb88 ^?
    alignment 3
    size 8
    offset 0x9e6058 _OBJC_IVAR_$_NSKeyValueContainerClass._cachedSetObservationInfoImplementation 24
    name 0x90bd5b _cachedSetObservationInfoImplementation
    type 0x92bb88 ^?
    alignment 3
    size 8
    offset 0x9e6060 _OBJC_IVAR_$_NSKeyValueContainerClass._cachedSetObservationInfoTakesAnObject 32
    name 0x90bd83 _cachedSetObservationInfoTakesAnObject
    type 0x92a01a B
    alignment 0
    size 1
    offset 0x9e6068 _OBJC_IVAR_$_NSKeyValueContainerClass._notifyingInfo 40
    name 0x90bdaa _notifyingInfo
    type 0x92bdd7 ^{?=##^{__CFSet}^{__CFDictionary}{os_unfair_recursive_lock_s={os_unfair_lock_s=I}I}B}
    alignment 3
    size 8

    offset 0x8 name:_originalClass type:Class


    offset 0x28 name:_notifyingInfo type:struct


    _notifyingInfo 结构体

    {
    Class,
    Class,
    __CFSet,
    __CFDictionary,
    os_unfair_recursive_lock_s
    }

    type encoding:


    developer.apple.com/library/arc…


    从 context 可以看出_NSKVONotifyingCreateInfoWithOriginalClass 这个方法入参是 _OBJC_IVAR__NSKeyValueContainerClass._originalClass。返回值 x0 是 _OBJC_IVAR__NSKeyValueContainerClass._notifyingInfo。5 次对 [x0] 的写入是在初始化 _notifyingInfo。


    崩溃时的 context:

        0x1825231f0 <+56>:  bl     0x1880967c0 // object_getClass
    0x1825231f4 <+60>: bl 0x1880967e0 // object_getIndexedIvars
    0x1825231f8 <+64>: mov x23, x0 // x0 == _notifyingInfo
    0x1825231fc <+68>: add x24, x0, #0x20 // x24 == os_unfair_recursive_lock_s
    0x182523200 <+72>: mov x0, x24
    0x182523204 <+76>: mov w1, #0x0
    0x182523208 <+80>: bl 0x188096910 // os_unfair_recursive_lock_lock_with_options crash 调用栈

    调用 object_getClass 获取 Class,调用 object_getIndexedIvars 获取到 _notifyingInfo,_notifyingInfo + 偏移量 0x20 获取 os_unfair_recursive_lock_s,崩溃的原因是这把锁的 owner 损坏了,lock 也是一个结构体,ower 也是根据 offset 获取的。


    结论


    从崩溃的上下文来看,最可能出问题的是获取 _notifyingInfo,因为只有 KVO  Class 才能获取到 _notifyingInfo 这个结构体,如果在调用 __NSSetBoolValueAndNotify 的过程中,在其它线程监听被移除,此时 object_getClass 取到的不是 KVO Class 那后续再根据 offset 去取 lock,这个时候就有可能发生上述崩溃。


    线下暴力复现验证了上述猜测。

    - (void)start {
    __block KVObject *obj = [KVObject new];
    dispatch_async(dispatch_get_global_queue(0, 0x0), ^{
    for (int i = 0; i < 100000; i++) {
    [obj addObserver:self forKeyPath:@"value" options:0x7 context:nil];
    [obj removeObserver:self forKeyPath:@"value"];
    }
    });

    dispatch_async(dispatch_get_global_queue(0, 0x0), ^{
    for (int i = 0; i < 100000; i++) {
    obj.value = YES;
    obj.value = NO;
    }
    });
    }

    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {}

    解决这个问题的思路就是保证线程安全,我们在线上断点找到了 removeObserver 的代码,将 removeObserver 和触发监听的代码放在了同一个串行队列。当然如果 removeObserver 在 dealloc 里面,理论上也不会出现这类问题。


    __NSSetxxxValueAndNotify 系列方法都有可能会触发这个崩溃,类似的问题可以按照相同的思路解决。

    00000000004e05cd t __NSSetBoolValueAndNotify
    00000000004e0707 t __NSSetCharValueAndNotify
    00000000004e097b t __NSSetDoubleValueAndNotify
    00000000004e0abc t __NSSetFloatValueAndNotify
    00000000004e0bfd t __NSSetIntValueAndNotify
    00000000004e10e7 t __NSSetLongLongValueAndNotify
    00000000004e0e6f t __NSSetLongValueAndNotify
    00000000004e0491 t __NSSetObjectValueAndNotify
    00000000004e15d5 t __NSSetPointValueAndNotify
    00000000004e1734 t __NSSetRangeValueAndNotify
    00000000004e188a t __NSSetRectValueAndNotify
    00000000004e135f t __NSSetShortValueAndNotify
    00000000004e19e8 t __NSSetSizeValueAndNotify
    00000000004e0841 t __NSSetUnsignedCharValueAndNotify
    00000000004e0d36 t __NSSetUnsignedIntValueAndNotify
    00000000004e1223 t __NSSetUnsignedLongLongValueAndNotify
    00000000004e0fab t __NSSetUnsignedLongValueAndNotify
    00000000004e149a t __NSSetUnsignedShortValueAndNotify
    00000000004de834 t __NSSetValueAndNotifyForKeyInIvar

    作者:yuec
    链接:https://juejin.cn/post/7193677784097488933
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    让 Xcode 15 拥有建置给 macOS 10.9 的能力

    iOS
    免责声明:理论上而言,用这招类推可以建置给早期版本的 iOS。但实际上管不管用我就没法保证了,因为我不是 iOS 程式师。 本文专门给那些需要在新版本系统当中用新版本 Xcode 将祖产专案建置给早期系统版本的资工业者们。 Xcode 15 需要打 liba...
    继续阅读 »

    免责声明:理论上而言,用这招类推可以建置给早期版本的 iOS。但实际上管不管用我就没法保证了,因为我不是 iOS 程式师。



    本文专门给那些需要在新版本系统当中用新版本 Xcode 将祖产专案建置给早期系统版本的资工业者们。


    Xcode 15 需要打 libarclite 才能给早于 macOS 10.13 的系统建置应用程式。


    通用做法就是从 Xcode 14.2 或 Xcode 13 当中提取出 libarclite 套装,然后植入到 Xcode 15 当中。先开启 toolchains 资料夹:




    再把 libarclite 的东西放进去(也就是 arc 这个资料夹):




    然而,如果是 macOS 10.9 的话,事情还要复杂一个层次:


    macOS 14 Sonoma 开始的 SDK 几乎把整个 Foundation 当中的很多基础类型都重写了。这就导致之前那些被 Swift 从 Objective-C 借走的基础类型全部都得重新打上「NS」开头的后缀才可以直接使用。但这还有一个问题:NSLocalizedString 的建构子不能使用了,因为这玩意在 macOS 14 当中也是被(用纯 Swift)彻底重构的基础类型之一。Apple 毫不留情地给这些基础类型都下了全局的「@available(macOS 10.10, *)」的宣告: 



    这样一来,除了 libarclite 以外,还需要旧版 macOS SDK 才可以。虽然 macOS 13 Ventura 的 SDK 也可以凑合用,但(保险起见)笔者推荐 macOS 12 Monterey 的 SDK:Release macOS 12.3 SDK · alexey-lysiuk/macos-sdk (github.com)。该 SDK 的安置位置:




    再修改一下 Xcode 专案当中对 macOS SDK 的指定(不用理会 not found):




    这样应该就可以正常组建了。如果有提示说 Date 不符合最新版本要求的话,把 Date 改成 NSDate 即可。


    $ EOF.


    作者:ShikiSuen
    链接:https://juejin.cn/post/7242927398294421541
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    浅谈多人游戏原理和简单实现

    一、我的游戏史 我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿...
    继续阅读 »



    一、我的游戏史


    我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿是我挨得最狠的,以至于现在回想起来,屁股还条件反射的隐隐作痛。


    后来我骗我妈说我要学习英语、练习打字以后成为祖国的栋梁之才!让她给我买台小霸王学习机(游戏机),在我一哭二闹三上吊胡搅蛮缠的攻势下,我妈妥协了。就此我接触到了FC游戏。现在还能记得我和朋友玩激龟快打,满屋子的小朋友在看的场景。经常有家长在我家门口喊他家小孩吃饭。那时候我们县城里面有商店卖游戏卡,小卡一张5块钱,一张传奇卡25-40块不等(所谓传奇卡就是角色扮演,带有存档的游戏),每天放学都要去商店去看看,有没有新的游戏卡,买不起,就看下封面过过瘾。我记得我省吃俭用一个多月,买了两张卡:《哪吒传奇》和《重装机兵》那是真的上瘾,没日没夜的玩。


    再然后我接触到了手机游戏,记得那时候有个软件叫做冒泡游戏(我心目的中的Stream),里面好多游戏,太吸引我了。一个游戏一般都是几百KB,最大也就是几MB,不过那时候流量很贵,1块钱1MB,并且!一个月只有30Mb。我姑父是收手机的,我在他那里搞到了一部半智能手机,牌子我现在还记得:诺基亚N70,那时候我打开游戏就会显示一杯冒着热气的咖啡,我很喜欢这个图标,因为看见它意味着我的游戏快加载完成了,没想到,十几年后我们会再次相遇,哈哈哈哈。我当时玩了一款网游叫做:《幻想三国》,第一回接触网游简直惊呆了,里面好多人都是其他玩的家,这太有趣了。并且我能在我的手机上看到其他玩家,能够看到他们的行为动作,这太神奇了!!!我也一直思考这到底是怎么实现的!


    最后是电脑游戏,单机:《侠盗飞车》、《植物大战僵尸》、《虐杀原型》;网游:《DNF》、《CF》、《LOL》、《梦幻西游》我都玩过。


    不过那个疑问一直没有解决,也一值留在我心中 —— 在网络游戏中,是如何实时更新其他玩家行为的呢?


    二、解惑


    在我进入大学后,我选择了软件开发专业,真巧!再次遇到了那个冒着热气的咖啡图标,这时我才知道它叫做——Java。我很认真的去学,希望有一天能够做一款游戏!


    参加工作后,我并没有如愿以偿,我成为了一名Java开发程序员,但是我在日常的开发的都是web应用,接触到大多是HTTP请求,它是种请求-响应协议模式。这个问题也还是想不明白,难道每当其他玩家做一个动作都需要发送一次HTTP请求?然后响应给其他玩家。这样未免效率也太低了吧,如果一个服务器中有几千几万人,那么服务器得承受多大压力呀!一定不是这样的!!!


    直到我遇到了Websocket,这是一种长连接,而HTTP是一种短连接,顿时这个问题我就想明白了。在此二者的区别我就不过多赘述了。详细请看我的另一篇文章


    知道了这个知识后,我终于能够大致明白了网络游戏的基本原理。原来网络游戏是由客户端服务器端组成的,客户端就是我们下载到电脑或者手机上的应用,而服务器端就是把其他玩家连接起来的中转站,还有一点需要说明的是,网络游戏是分房间的,这个房间就相当于一台服务器。首先,在玩家登陆客户端并选择房间建立长连接后,A玩家做出移动的动作,随即会把这个动作指令上传给服务器,然后服务器再将指令广播到房间中的其他玩家的客户端来操作A的角色,这样就可以实现实时更新其他玩家行为。




    三、简单实现


    客户端服务端在处理指令时,方法必须是配套的。比如说,有新的玩家连接到服务器,那么服务器就应当向其它客户端广播创建一个新角色的指令,客户端在接收到该指令后,执行客户端创建角色的方法。
    为了方便演示,这里需要定义两个HTML来表示两个不同的客户端不同的玩家,这两套客户端代码除了玩家的信息不一样,其它完全一致!!!


    3.1 客户端实现步骤


    我在这里客户端使用HTML+JQ实现


    客户端——1代码:


    (1)创建画布

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas Game</title>
    <style>
    canvas {
    border: 1px solid black;
    }
    </style>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    </head>
    <body>
    <canvas id="gameCanvas" width="800" height="800"></canvas>
    </body>
    </html>

    (2)设置1s60帧更新页面

    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    function clearCanvas() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    }
    function gameLoop() {
    clearCanvas();
    players.forEach(player => {
    player.draw();
    });
    }
    setInterval(gameLoop, 1000 / 60);
    //清除画布方法
    function clearCanvas() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    }

    (3)连接游戏服务器并处理指令


    这里使用websocket链接游戏服务器

     //连接服务器
    const websocket = new WebSocket("ws://192.168.31.136:7070/websocket?userId=" + userId + "&userName=" + userName);
    //向服务器发送消息
    function sendMessage(userId,keyCode){
    const messageData = {
    playerId: userId,
    keyCode: keyCode
    };
    websocket.send(JSON.stringify(messageData));
    }
    //接收服务器消息,并根据不同的指令,做出不同的动作
    websocket.onmessage = event => {
    const data = JSON.parse(event.data);
    // 处理服务器发送过来的消息
    console.log('Received message:', data);
    //创建游戏对象
    if(data.type == 1){
    console.log("玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    console.log("玩家id:"+playerOfIds);
    createPlayer(data.players[i].playerId,data.players[i].pointX, data.players[i].pointY, data.players[i].color);
    }
    }
    //销毁游戏对象
    if(data.type == 2){
    console.log("玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    destroyPlayer(data.players[i].playerId)
    }
    }
    //移动游戏对象
    if(data.type == 3){
    console.log("移动;玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    players.filter(player => player.id === data.players[i].playerId)[0].move(data.players[i].keyCode)
    }
    }
    };

    (4)创建玩家对象

    //存放游戏对象
    let players = [];
    //playerId在此写死,正常情况下应该是用户登录获取的
    const userId = "1"; // 用户的 id
    const userName = "逆风笑"; // 用户的名称
    //玩家对象
    class Player {
    constructor(id,x, y, color) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.size = 30;
    this.color = color;
    }
    //绘制游戏角色方法
    draw() {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.x, this.y, this.size, this.size);
    }
    //游戏角色移动方法
    move(keyCode) {
    switch (keyCode) {
    case 37: // Left
    this.x = Math.max(0, this.x - 10);
    break;
    case 38: // Up
    this.y = Math.max(0, this.y - 10);
    break;
    case 39: // Right
    this.x = Math.min(canvas.width - this.size, this.x + 10);
    break;
    case 40: // Down
    this.y = Math.min(canvas.height - this.size, this.y + 10);
    break;
    }
    this.draw();
    }
    }

    (5)客户端创建角色方法

    //创建游戏对象方法
    function createPlayer(id,x, y, color) {
    const player = new Player(id,x, y, color);
    players.push(player);
    playerOfIds.push(id);
    return player;
    }

    (6)客户端销毁角色方法


    在玩家推出客户端后,其它玩家的客户端应当销毁对应的角色。

    //角色销毁
    function destroyPlayer(playId){
    players = players.filter(player => player.id !== playId);
    }

    客户端——2代码:


    客户端2的代码只有玩家信息不一致:

      const userId = "2"; // 用户的 id
    const userName = "逆风哭"; // 用户的名称

    3.2 服务器端


    服务器端使用Java+websocket来实现!


    (1)引入依赖:

     <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.1.2.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
    <version>2.3.7.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.11</version>
    </dependency>
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
    </dependency>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.16.16</version>
    </dependency>
    <dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.6.3</version>
    </dependency>

    (2)创建服务器

    @Component
    @ServerEndpoint("/websocket")
    @Slf4j
    public class Server {
    /**
    * 服务器玩家池
    * 解释:这里使用 ConcurrentHashMap为了保证线程安全,不会出现同一个玩家存在多条记录问题
    * 使用 static fina修饰 是为了保证 playerPool 全局唯一
    */
    private static final ConcurrentHashMap<String, Server> playerPool = new ConcurrentHashMap<>();
    /**
    * 存储玩家信息
    */
    private static final ConcurrentHashMap<String, Player> playerInfo = new ConcurrentHashMap<>();
    /**
    * 已经被创建了的玩家id
    */
    private static ConcurrentHashMap<String, Server> createdPlayer = new ConcurrentHashMap<>();

    private Session session;

    private Player player;

    /**
    * 连接成功后调用的方法
    */
    @OnOpen
    public void webSocketOpen(Session session) throws IOException {
    Map<String, List<String>> requestParameterMap = session.getRequestParameterMap();
    String userId = requestParameterMap.get("userId").get(0);
    String userName = requestParameterMap.get("userName").get(0);
    this.session = session;
    if (!playerPool.containsKey(userId)) {
    int locationX = getLocation(151);
    int locationY = getLocation(151);
    String color = PlayerColorEnum.getValueByCode(getLocation(1) + 1);
    Player newPlayer = new Player(userId, userName, locationX, locationY,color,null);
    playerPool.put(userId, this);
    this.player = newPlayer;
    //存放玩家信息
    playerInfo.put(userId,newPlayer);
    }
    log.info("玩家:{}|{}连接了服务器", userId, userName);
    // 创建游戏对象
    this.createPlayer(userId);
    }

    /**
    * 接收到消息调用的方法
    */
    @OnMessage
    public void onMessage(String message, Session session) throws IOException, InterruptedException {
    log.info("用户:{},消息{}:",this.player.getPlayerId(),message);
    PlayerDTO playerDTO = new PlayerDTO();
    Player player = JSONObject.parseObject(message, Player.class);
    List<Player> players = new ArrayList<>();
    players.add(player);
    playerDTO.setPlayers(players);
    playerDTO.setType(OperationType.MOVE_OBJECT.getCode());
    String returnMessage = JSONObject.toJSONString(playerDTO);
    //广播所有玩家
    for (String key : playerPool.keySet()) {
    synchronized (session){
    String playerId = playerPool.get(key).player.getPlayerId();
    if(!playerId.equals(this.player.getPlayerId())){
    playerPool.get(key).session.getBasicRemote().sendText(returnMessage);
    }
    }
    }
    }

    /**
    * 关闭连接调用方法
    */
    @OnClose
    public void onClose() throws IOException {
    String playerId = this.player.getPlayerId();
    log.info("玩家{}退出!", playerId);
    Player playerBaseInfo = playerInfo.get(playerId);
    //移除玩家
    for (String key : playerPool.keySet()) {
    playerPool.remove(playerId);
    playerInfo.remove(playerId);
    createdPlayer.remove(playerId);
    }
    //通知客户端销毁对象
    destroyPlayer(playerBaseInfo);
    }

    /**
    * 出现错误时调用的方法
    */
    @OnError
    public void onError(Throwable error) {
    log.info("服务器错误,玩家id:{},原因:{}",this.player.getPlayerId(),error.getMessage());
    }
    /**
    * 获取随即位置
    * @param seed
    * @return
    */
    private int getLocation(Integer seed){
    Random random = new Random();
    return random.nextInt(seed);
    }
    }

    websocket配置:

    @Configuration
    public class ServerConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
    return new ServerEndpointExporter();
    }
    }


    (3)创建玩家对象


    玩家对象:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class Player {
    /**
    * 玩家id
    */
    private String playerId;
    /**
    * 玩家名称
    */
    private String playerName;
    /**
    * 玩家生成的x坐标
    */
    private Integer pointX;
    /**
    * 玩家生成的y坐标
    */
    private Integer pointY;
    /**
    * 玩家生成颜色
    */
    private String color;
    /**
    * 玩家动作指令
    */
    private Integer keyCode;
    }

    创建玩家对象返回给客户端DTO:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PlayerDTO {
    private Integer type;
    private List<Player> players;
    }

    玩家移动指令返回给客户端DTO:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PlayerMoveDTO {
    private Integer type;
    private List<Player> players;
    }


    (4)动作指令

    public enum OperationType {
    CREATE_OBJECT(1,"创建游戏对象"),
    DESTROY_OBJECT(2,"销毁游戏对象"),
    MOVE_OBJECT(3,"移动游戏对象"),
    ;
    private Integer code;
    private String value;

    OperationType(Integer code, String value) {
    this.code = code;
    this.value = value;
    }

    public Integer getCode() {
    return code;
    }

    public String getValue() {
    return value;
    }
    }

    (5)创建对象方法

      /**
    * 创建对象方法
    * @param playerId
    * @throws IOException
    */
    private void createPlayer(String playerId) throws IOException {
    if (!createdPlayer.containsKey(playerId)) {
    List<Player> players = new ArrayList<>();
    for (String key : playerInfo.keySet()) {
    Player playerBaseInfo = playerInfo.get(key);
    players.add(playerBaseInfo);
    }
    PlayerDTO playerDTO = new PlayerDTO();
    playerDTO.setType(OperationType.CREATE_OBJECT.getCode());
    playerDTO.setPlayers(players);
    String syncInfo = JSONObject.toJSONString(playerDTO);
    for (String key :
    playerPool.keySet()) {
    playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
    }
    // 存放
    createdPlayer.put(playerId, this);
    }
    }

    (6)销毁对象方法

       /**
    * 销毁对象方法
    * @param playerBaseInfo
    * @throws IOException
    */
    private void destroyPlayer(Player playerBaseInfo) throws IOException {
    PlayerDTO playerDTO = new PlayerDTO();
    playerDTO.setType(OperationType.DESTROY_OBJECT.getCode());
    List<Player> players = new ArrayList<>();
    players.add(playerBaseInfo);
    playerDTO.setPlayers(players);
    String syncInfo = JSONObject.toJSONString(playerDTO);
    for (String key :
    playerPool.keySet()) {
    playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
    }
    }

    四、演示


    4.1 客户端1登陆服务器




    4.2 客户端2登陆服务器




    4.3 客户端2移动




    4.4 客户端1移动




    4.5 客户端1退出



     完结撒花


    完整代码传送门


    五、总结


    以上就是我对网络游戏如何实现玩家实时同步的理解与实现,我实现后心里也释然了,哈哈哈,真的好有趣!!!
    我希望大家也是,不要失去好奇心,遇到自己感兴趣的事情,一定要多思考呀~


    后来随着我经验的不断积累,我又去了解了一下Java作为游戏服务器的相关内容,发现Netty更适合做这个并且更容易入门,比如《我的世界》一些现有的服务器就是使用Netty实现的。有空也实现下,玩玩~


    作者:是江迪呀
    链接:https://juejin.cn/post/7273429629398581282
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »