dreamapplehappy

dreamapplehappy 查看完整档案

杭州编辑杭州电子科技大学  |  软件工程 编辑艺术家  |  我喜欢这个功利的世界 编辑 github.com/dreamapplehappy/blog 编辑
编辑

微信公众号「关山不难越」
除了代码,生活还有很多值得经历和体味的事情;去旅游,去看不一样的风景;活,该快乐!

无欲速,无见小利。欲速则不达,见小利则大事不成

个人博客:https://github.com/dreamapple...

个人动态

dreamapplehappy 关注了用户 · 1月22日

敖丙 @aobing

关注 5799

dreamapplehappy 赞了文章 · 1月22日

一年40W粉,小小程序员的2020年终总结

这一年世界发生了太多事,山火、疫情、洪水、大选.......魔幻而过得飞快。

而这一年对我来说,是机遇,也是挑战,所幸付出有回响,努力有回报。自媒体这条路不知不觉我就走了一年,从200天、300天的时候就在盼这一天,总感觉坚持一年就可以给自己一个交代了。

等真的到了这一天,心里反而有点平静了,又赶上双十一、搬家,忙着忙着也就过去了(忘了)。直到这两天得了流感生病在家,这才想起来应该总结一下,是对过去的一次复盘,也是对未来的一份期许。

故事的开始总是猝不及防,杭州深秋,夜雨淅淅沥沥。

下班后的办公室静悄悄,三歪窝在电脑前,手指翻飞,不时皱着眉头停下来思考,他还在肝文章。

我就着新相机的新鲜感,又拍摄了几个镜头。掏出手机翻了翻自己b站视频播放,三天了,数字还是没有突破200,这还是我已经发了朋友圈的结果,想到朋友圈也就300人,我好像又明白了些什么。

心里有点闷,我走过去拉了把椅子坐下,还没等我开口。

“今天的拍完了?你的B站我看了,说实话有点垃圾”

“我也知道,说实话那种东西有人看才有鬼了”

mogu的logo在雨里模糊成粉色的光,我长叹了一口气,三歪拿起一根华子递给我,我摆摆手,指向他的屏幕。

“你觉得我可以么”

“有什么不可以,你这么有才,还幽默,在我心里你就是最棒的”

“那我真的写咯”

“写呗,这样周末还有个伴,陪我写文章哈哈”

我们都没意识到,那天无意的对话改变了我这一年的轨迹。

三歪说既然视频没做出啥起色,就试试文章,我这一试就写到了今天。

从第一篇文章开始,我就霸占了当时博客网站的热榜,这给了我极大的动力。

来新东家的那次跳槽我拿了很多offer,我一想自己面试这么擅长为啥不写面试题材的?因为性格不羁的原因我就想取个浮夸的名字,就有了当时很火的《吊打面试官》系列,后面模仿的小伙伴越来越多,甚至发现很多大学生也取这种标题,意识到自己好像做了反面教材,连夜把全网这个系列文章的名字都改了,后来为这事还上了次热搜,被人匿名喷我带坏了风气。

言语无力,索性更努力的改善自己内容的广度深度,到现在虽然没了系列,不过文风却更多样化了。

开篇说了最开始我目标是做视频博主,后来却在写文的路上越走越远。

所谓念念不忘,必有回响,疫情期间没忍住,最终还是买了相机开始折腾。星球的小伙伴问的问题总是面试相关,我就想着不如就来个模拟面试,这样拍成视频大家都能看到。

后来的事情大家也知道了,拍着拍着突然就火了,在B站也带起了一波面试的节奏。

至于最近为啥断更几个月,忙是一方面,还有一方面在思考内容的扩展,我可以通过面试视频涨很多很多粉,但更想通过内容留住更多的人,毕竟大家也不会喜欢看一个只会面试的人一辈子吧,是吧?

再说说生活,老粉就能发现,其实下半年我特别能折腾,先是去深圳见了帅地,uzi、良许,吴师兄他们。

又在苏州和互联网技术号的头部们碰了一面,这次见的大佬们很多我都是看着他们文章成长的,反正我是没想到网罗灯下黑的主人是个帅气大叔,每日优搜罗的号主是个刚毕业的大帅比,还有拉皮条的勇哥。。。。。哦对,请我在上海坐豪华游轮的堂妹我也还是得单独说一下,毕竟是商汤的顶流程序员妹子。

入秋又跑了好几趟上海,在华为嘉年华上面基了Chris和在下小苏他们,在B站邀请会见到了变形兄弟,凉仔,毕导这些大博主,已经约了明年的一些梦幻联动,大家也可以期待一下。

我合作方也多了腾讯,B站,华为,阿里云,拼多多等这样的大企业,从产品的使用者,到他们的推广者,总算也有能洽上心仪饭的一天了。

这些人和企业在年初对我来说,还只是遥不可及的名字,所以接触的时候还是心里一颤,惊觉自己原来已经走了这么远,站的这么高。

今年最最让我满意的就还是挑起了家里的大梁吧,年初爸爸所在的工厂因为疫情要很迟才开工,我索性叫他别出去打工了,在家随便找个工作混着,老妈呆家里就好了,赚钱养家的事情就我来吧。

老家路远又难走,给爸爸买了车,这样出门能方便点,外婆也可以经常接来住。国庆回家,看到爸妈和外婆在家生活的富足,就觉得终于也可以给家里遮风挡雨了。(知道我为啥要恰辣么多饭了吧,因为我赚了着一家人的工资哈哈哈哈)

一切看起来都是好的,这中间有什么不好的么?

有人经常问我,你是怎么做到这么高产出的呀,我的回答其实一般都是比较直接,两个字 ”熬夜“,至于周末嘛,我好像一年都没完整的过过周末,就算出去玩也都提前肝好存货出门的。

这么肝,身体免疫力会很差劲,我本身就不是强壮的人,我来杭州四年了,每年的11月12月我都会得流感,这不这两天又中招了,大家好奇我怎么会有发热门诊护士的微信,真的不是因为我骚,是因为我去得太勤了想记不住都难。

说到这里,大家是不是一直对我感情也挺好奇的,其实去年我有交往过一个模特女朋友,她很好,但是我要忙的太多,忽略了很多细节,别说她,是我我也要跟敖丙这种人分手,都没恋爱的感觉,呸渣男。。。

注:照片经过本人同意 我知道后台100%会有又喷我别乱发别人照片,我所有照片会经过别人同意的哟乖,自媒体版权这件事情我比大家认知都深刻

至于现在,虽然身边有很多不错的机会,但是我是那种口嗨着好惨啊单身狗,但是别人一介绍就NONONONO的人,而且我觉得现阶段的自己这样挺好,等我确定自己能有精力付出和陪伴另一半的时候, 再去迎接到来的感情吧。

说回工作,大家也知道年初公司就因为裁员上了热搜,很多好朋友虽然现在已经不在mogu了,但是我们还是经常聚会爬山聚餐什么的。我自己呢也来到了大数据团队,我算是从电商的业务线,中台线,又到了大数据底层侧了,有朋友可能会比较烦这种变动,好不容易熟悉的又给我搞到不熟悉的,但我这个人就是喜欢充满挑战性的事情,所以放马过来吧。

这一年,在全网积累了40多万的粉丝,在程序员自媒体里面确实已经算得上头部顶流,在公司同事也总是调侃着喊我大v,但是因为见过更高的山更厉害的人,所以更知道自己要走的路还有很远,也没把自己当回事,认识我的人呢把我当正常人就好了。

做自媒体这件事情我运气还是挺好的,熬过的夜,码下的字也没有被辜负。最开始我高产是因为发现我有趣的文章大家看了有帮助,也被很多人喜欢,对我来说足以。不过现在呢大家也看到了我广告不少,反正被骂习惯了,熟悉的朋友也发现了多少个广告对应多少个原创,这也是我周末不到处玩,看书学习更新的动力,我觉得既然恰了饭,就一定要拿出干货对得起大家。

不然去跟妹子喝喝酒,爬爬山还是很惬意的呀,谁愿意天天在位置上顶着腰疼,顶着孤独做这些呢是吧,还可能被喷自闭。

我不是什么清高的人,我比任何人都喜欢钱,也比任何人都在意赚钱这件事情,从实习几千工资到现在,我穷怕了,我一直没放弃过研究如何赚钱,如何钱生钱。不过君子爱财,取之有道,我也有自己的坚守。

其实我完全可以赚更多的钱,你们看我文章会发现我今年几乎都不开赞赏功能,也不使用腾讯团队内测的付费功能,B站直播我也拒绝大家刷东西。

还有大家也发现我的博客网站,视频网站,目前都没什么恰饭,并不是缺广告商,主要是因为我觉得既然公号恰了,别的地方就算了,还大家一片清净,还有朋友圈和交流群,虽然好多人拿着不菲的广告费找过我,但我都一口拒了,一码归一码,这也是我最后的坚守。

这一年,从跳槽来新东家,经历裁员,疫情,从上海的夜晚到北京的深秋,见过半夜的西湖,也顶着晨光回家,经历得越多,认识的人越多,知道的故事就越多,每个人都有不一样的生活,都有不被限制而闪闪发光的灵魂,而我又有这样的平台,作为为数不多的程序员自媒体,我还是希望内容的本身能给大家带来想要收获的同时,也分享身边有趣的人和事,知道这个世界上有这样一群人,以这样的方式活的酣畅淋漓。

其实一周年的文章在我生病的时候写有点多愁善感,不过万千感慨不吐不快,我也一直留着汗在写,如果说错什么,还望海涵。

感谢这一年里每一份关注,愿你我不负相遇,都有收获。就像我们第一次在这里见面那样,我说希望你在这里悄悄拔尖,然后惊艳所有人。

我是敖丙,你知道的越多,不知道的越多,我们下期见,respect。

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。
查看原文

赞 5 收藏 1 评论 3

dreamapplehappy 赞了文章 · 1月22日

2020 结点:平凡 & 重新出发

2020 年,庚子年,注定是不平凡的一年,所以就平凡的过去了。年初,疫情让我在家办公了几个月,年中开始了忙碌的几个月,年底又归于平凡。也因为疫情,多了一些 beach 的时间,不得不休完 20 天的看似,还有没机会用上的婚假,所以我有机会尝试一些新的想法。

太长不读版:

  • 编程上,回到底层/系统编程,构建基础设施开发能力。
  • 写作上,在 Ledge 项目上结合前端可视化,展示了知识管理的另一种可能性。
  • 设计上,依旧还在一天一张画的练习上,暂时没有新的突破。
  • 方法化上,丰富和完善了 DevOps/系统重构相关等知识体系。
  • 影响力上,靠影响力带来了几个公司的项目,除此没有进展。

好像也没了。再对比一下上一年的目标:

  1. 工具,有了更多编程语言、软件工程相关的工具。
  2. DSL 抽象,设计的 DSL 主要集中在 Charj 相关的项目上,缺少对业务的抽象。
  3. 国际化,几乎没有任务长进。相反的,在做本土化的各种实施。
  4. 婚礼,被迫放在 2021 年了。

嗯,大部分都没有实现,反正计划就是计划嘛 :) 。

编程

综合疫情带来的 beach 时间,加上外地出差,额外获得了很多的编码时间。

项目相关

这一年的项目多少是有些无聊,设计一些方案,指导实施方案的落地,再做一些度量。

参与了某国产操作系统的 IDE,深入了解 Android Studio 和 Intellij Community 背后相关的知识、各类实践。真正意义上,掌握了编程语言端到端的实践 —— 从开发、构建、优化,再到执行等一系列的过程。源码阅读上,包含但是不限于 Gradle、Proguard、R8/D8、JVM、Intellij Community 等。

底层编程 + Rust

在那了篇《六年之后:回到底层编程》里,我开始了底层编程之旅。

  1. Electron + Rust 设计 RPC 架构下的客户端:Stadal
  2. 可执行的 markdown 工具 exemd (支持依赖):exemd
  3. Scie 代码识别引擎:Scie
  4. ……

不过,就目前的情况来看,道路依旧还有点长,需要重新掌握的知识有很多 —— 毕竟以前看会的,和现在真正动手的是两码事。

重构工具

在这一年里,与工作相关的一部分话题依然是重构。所以,也利用了大量的业余时间。

  • 更完善的分析工具:Coca
  • 多语言分析工具:Chapi
  • Ant 转 Maven 工具:Merry
  • 和同事搞的 CSS 重构工具:Lemonj

有意思的是,这几个项目的技术栈是:Go + Antlr、Kolint + Antlr、Go + Antlr、TypeScript + Antlr ……。嗯 ,真的是只要涉及编程语言相关、DSL 相关,Antlr 就是一个非常不错的工具。

DSL 与 Charj

快到年底的时候,和我同事一起开启了 Charj 语言的坑,也是为自己的未来找一些有意思的事情干。我们日常做项目的时候,最难的就是启动一个项目 —— 要搭建架子,相当于设计架构。所以,在这一年里,努力地把整个架子搭建了起来。

这一个也作为了下一年,或者是未来几年的的一个方向。(PS:有兴趣的话,欢迎入坑,微信:phodal02 (注明来意))

写作

写作最重要的是,构建成了一个完整的体系。虽然我平时写的文章多,看上去没有体系,但是还是有一些基本的体系的 —— 也就是围绕着我要去做的东西。

万物代码化

关于这部分内容的总体思路:《万物代码化:从低代码、云开发到云研发》,这部分的各部分文章见:

完整内容见:https://github.com/phodal/asc...

知识体系构建

工作时间越长,越发现知识体系的重要性。哪怕是写了一系列的文章,查阅的时候,也算是过于分散了。在这一年里,主要梳理了这两部分的知识体系:

  • 《遗留系统重构指南》:https://github.com/phodal/mig... 。 手把手教你分析、评估现有系统、制定重构策略、探索可行重构方案、搭建测试防护网、进行系统架构重构、服务架构重构、模块重构、代码重构、数据库重构、重构后的架构守护。我原以为这是一个很小众的领域,没想到年底的时候一看,GitHub 上有 2k 的 star。
  • DevOps 知识体系:https://github.com/phodal/ledge 。基于在 ThoughtWorks 进行的一系列 DevOps 实践、敏捷实践、软件开发与测试、精益实践提炼出来的知识体系。它包含了各种最佳实践、操作手册、原则与模式、度量、工具,用于帮助您的企业在数字化时代更好地前进,还有 DevOps 转型。 反而是我看好的这个项目,GitHub 上的 star 只有 1.3k 。

接下来要做的事情就是,在适当的时候构建下一个知识体系。

其它

其它多数为一些总结,可以在未来用到。又或者是诸如『编程语言开发』这一个还不成统的话题。

设计

没有特别突出,依旧是画画。

不过,画得似乎越来越普通了?

唯一发生的变化是,我换了新的产生力(爱-奇-艺)工具:iPad Pro 11 + Apple Pencil 2。

其它

我一直有一个想法是:建设一个开源梯队。不过按国内的加班情况来看,这种可能性并不是很大。只能试着围绕 Charj 来构建开源社区了。

Helo, 2021

简单,然后专注,这就够了。

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。
查看原文

赞 2 收藏 0 评论 0

dreamapplehappy 发布了文章 · 1月22日

2020年,我第一次很正式地写年终总结

好像我一直没有写年终总结的习惯,之前也一直都是看别人写年终总结。有时看别人的年终总结,会感叹别人这一年过的好充实。看了很多书,去了很多地方;或者结识了很多新的朋友,能力得到了很多的提高等等吧。也常常会感觉自己这一年好像没干什么事情,会有那么一点沮丧。

当我写这篇文章的时候,我突然意识到自己好像已经忘记了2020年定下的什么目标了。不难想象自己当时定下目标的时候肯定是踌躇满志的,没想到已经连定下的什么目标都忘记了

所以写一下年终总结对我来说还是很有必要的,一方面可以记录一下自己这一年做了哪些事情。另一方面也可以记录自己对来年的一个目标,等到下一年再次写年终总结的时候就有了一个对比。可以知道自己是进步了还是倒退了,可以看看自己之前定下的目标都实现了多少。这样我觉得也挺好的,今年就作为一个开始吧。

工作和技术分享,输出了14篇文章

2020年的后半年加入了一家中型的互联网公司,再次开启了自己的职场生涯。我也重拾了自己写博客的习惯。作为一个开发者,写博客在目前来说依然是一个不错的方式让大家认识和了解你。对于自己来说,写博客可以记录和沉淀自己学习过的知识或者解决的问题。如果自己下次再遇到类似的问题,就知道如何去解决,如果忘记了就查找一下之前的博客。

写博客也是一个强化自己知识的过程。很多时候你以为你掌握了,但是真的到把这些知识写成博客,然后想要分享给别人的时候,你会发现自己好像并没有掌握的很牢固,也会有一些不熟练的地方。而写博客这个过程恰恰就是帮助你把这些不熟悉的地方打通的一个很好的方法

关于写博客我自己也有一些心得,想在这里跟大家分享一下。我觉得写博客最重要的其实是坚持,然后是自己对博客质量的要求,最后是平台的选择

先说一下坚持吧,我感觉每一个前端工程师或多或少都倒腾过自己的博客。我最早写博客是在 CSDN 和博客园,后来又转移到思否,掘金和知乎上,也使用过 Hexo,以及 Ghost 搭建过自己的博客。反正前前后后换了很多平台,但是文章却没留下来几篇。所以后来我就决定不再纠结在那个平台写博客了。而是把精力放在坚持写博客上,我们可以看看,基本上那些技术很厉害的大牛也都是专注于自己博客的内容,而不是纠结博客的形式。所以我后来就把我自己的博客放在了Github上,简简单单的,反而让我有了坚持写下去的动力。看着文章一篇一篇增加,也是会有一点成就感的

然后就是博客的质量了,当我们能够坚持写博客之后,接下来就需要专注于我们博客的质量。我们写博客大多是为了分享给别人或者用做记录以便自己查看。如果是分享给别人看的话,如何让大家从你的博客中学习到东西,或者能够吸引大家来看就是一个需要考虑的问题了。一般来说,只要我们能够把要分享的内容讲清楚,内容逻辑条理清晰。这样下来,文章的质量一般不会太差。如果你的文笔还不错那就又是一个加分项了。

持续高质量的文章输出很快就会给你带来很多的关注者,也会有很多厉害的人主动跟你联系。这样下来,你通过写博客不但能够提升自己的技术,提高自己的知名度,还可以跟优秀的人建立联系。这些都是你的宝贵财富,也都是可以转换为你自己实实在在的利益的。更重要的是,你写下的文章随着时间的推移,看到的人会越来越多,会持续给你带来更多收获的

最后就是平台的选择了,不同平台用户感兴趣的不一样,用户活跃度也不一样。这个要根据你自己的文章在各个平台的数据来判断,可以选择一两个作为主要的分享平台,其它的作为辅助。选择好一个合适的平台后,可以多跟这个平台的用户保持积极的沟通交流,你会收到来自大家的肯定,这会让你有继续坚持写下去的动力。

2020年写的一些文章

在2020年我一共写了14篇博客,不算很多,但是每一篇博客我都写得很用心。我个人在数量和质量上进行权衡的时候,我还是比较看重质量的。当然在接下来的2021年,我希望自己能够尽量半个月写一篇文章。在质量不下降的情况下,提升写博客的数量。

之前的博客主要围绕两个主题来写的,一个是关于设计模式的。我把这个系列称作设计模式大冒险。另一个系列是关于正则表达式的,这个系列最近的更新比较少了。主要是如果要深入的讲解好正则表达式,需要学习的知识很多;我也不希望自己一知半解的就去写一篇文章。这样是对自己文章和读者的不负责。在新的一年,如果精力允许的话我还会继续更新关于正则表达式的一些文章,希望大家可以保持关注。

细心的你也许会发现,我分享的知识都是不限于某一种语言的。我个人认为,学习知识应该学习通用的,适用范围更广的。比如上面我分享的设计模式正则表达式,以及数据结构算法,还有编程的思想等等。这些知识一旦掌握了,在每一种编程语言上面都可以使用。用世俗的评判标准就是“性价比”比较高

当然我不是说你就不需要对某一种语言,某一个框架,或者某一个方向深入研究了。这些也是很有必要的。只不过有些类似的东西,你掌握好一个就可以了。你的时间和精力是有限的,要把时间放在更有用的地方。当然,如果是因为工作的需要的话,那就是另外一回事了。工作的事情还是要做好的,要首先把自己工作需要掌握的知识掌握熟练之后,再考虑去学习其它的提升自己的知识。

上面是关于写博客,学习技术和技术分享的事情。接下来分享一些关于开发过程中的心得。我们在平时的开发中,如果遇到一些不是很好开发的需求,一定要问一下产品做这个功能的目的是为了什么。不要上来就去开始写代码了,很多情况下想要实现一个功能有很多种解决办法,产品告诉你的方式也许是一个不好实现的方式。这个时候你就可以跟产品沟通换一种方式是否可行,很多情况下经过我们沟通之后,发现可以用一种简单的方式实现同样的效果。

多跟产品沟通,多思考为什么要这样做。这样,在以后的开发过程中,你会减少很多不必要的编码劳动,也能够工作的比较舒服一点

主线程小程序的持续维护

关注我的一些朋友知道我跟我的女朋友在2019年做了一个创业项目,是一个学习打卡类小程序,小程序的名字是主线程,如果你想了解更多可以看一下之前思否的一个小采访,如果全身心投入1年,但是收入是0,你还愿意做独立开发者吗?

主线程小程序的一些截图

在2020年的4月份我们决定暂停主线程项目的继续开发,只做日常的系统维护工作。因为当时的经济状况已经不允许我们继续投入到这个项目中去了。这个决定在目前的我们看来还算是一个正确的决定。因为如果继续下去,我们可能会面临一系列的问题和压力。这些问题和压力对于当时的我们来说大概率是解决不了的

那么,主线程这个项目成功了吗?这个问题要看你是从哪个角度去看了。如果单纯从世俗的金钱回报上说,我们肯定是失败了,而且失败得很彻底。不仅没有赚到一分钱(除去一些赞赏),还倒贴了一笔钱。但是如果站在另外一个角度看的话,我觉得对于我们两个来说还是很成功的。首先我们从0到1把这个产品做了出来,并且这个产品也得到了不少用户的好评,一些关于产品的体验评价可以在这里浏览哦。到目前为止,在没有花钱推广的情况下已经有15000+的用户了。也算是一个小小的成就吧

每当收到用户对主线程的夸奖的时候,我们那种开心和成就感是用金钱换不来的。其实最重要的收获是在于我们对创业,对产品和看待事情的角度都发生了变化。以前的角色是作为公司的员工,现在是以一个创业者的身份来看待和处理这些事情,这中间我们学习到了很多

总之我们不后悔花费了一年的时间开发了一个没有盈利的产品,接下来我们还会继续维护这个项目。如果你有什么关于创业和开发产品的想法想跟我交流的,也欢迎添加我的个人微信(请备注来意),我们一起沟通交流一下吧。

身体健康和读书

去年前半年因为没有上班,所以有比较多的时间锻炼身体。跑步基本每周有两三次,那段时间经历也还算充沛。后半年上班之后,时间比较少,再加上冬天也比较冷。跑步的次数就很少了,从Kepp上的记录来看,我后半年关于做俯卧撑的锻炼就只做了13次,因为锻炼比较少。可以明显感觉到每天工作到快晚上下班的时候会比较疲惫。

也因为工作的原因,久坐在办公室,颈椎和腰有时也会感觉到有点不舒服。关于吃饭,好的一点是去年点外卖的次数变少了。去外面小餐馆吃饭的次数变多了。我个人会觉得出去走动一下挺好的,呼吸一下外面的新鲜空气,给自己换个心情。走路去外面吃个饭也算是一个小小的锻炼吧

去年读书没有读很多,《增长黑客》 这本书基本读完了,感觉收获很多。对于想创业的朋友应该很有帮助的,推荐感兴趣的可以读一读。还有一本书是 《丰子恺:无宠不惊过一生》 ,还没读完,不过每次读都会给我一些启发,给我带来一些内心的平静。感兴趣的也可以读一下。

因为短视频的兴起,感觉自己身边很多的人都没有了读书的习惯了。很多人可能都没有耐心读完一篇很长的文章了。我个人感觉这不是一个很好的习惯,我之前也有一段时间对抖音比较沉迷,每周在上面花费的时间有4,5个小时。后来我意识到,看这些短视频并不能给我带来什么能力和技术的提升,大部分情况下都只是消耗了我的时间而已。所以我慢慢就不怎么看了,现在手机上的抖音虽然没有被我卸载,但是一直处于待更新的状态。

我觉得,知识的获取需要一个比较完整的体系。看书或者看一个系列的文章和视频在目前依然还是一个挺好的方式去获取知识。碎片化的知识会让你感觉自己掌握了很多知识,但是这些知识没有成为一个体系,没有融合进你自己的知识系统的话,这些知识是很难被你再次吸收和利用的。我自己平时比较喜欢在 《珍新闻》 上面获取一些资讯,个人感觉里面的文章都还是不错的,大部分能够把一个事情讲明白,讲解完整。很多文章也会带给你一些思考。如果感兴趣的话,可以体验一下。

新年的计划

迎接新的一年

关于去年的一些总结已经写得差不多了,接下来就是对2021的的一些展望了。下面是我列的一些想在2021年完成的一些目标。

  • 阅读12本书,希望自己可以一个月读一本书,书的种类不做限制,先培养自己读书的习惯
  • 博客继续更新,希望半个月可以写一篇。会继续更新设计模式大冒险系列,和正则表达式系列。应该还会加上数据结构和算法。之前写文章会比较纠结一些细节,导致写一篇文章用的时间比较久,新的一年我要把写文章的流程好好优化一下,争取在原来的基础上再多写几篇高质量的文章。
  • 争取每周可以锻炼一次,不管是跑步,俯卧撑锻炼还是爬山或者其他的运动。把身体搞好是很重要的事情
  • 多认识一些新的朋友,多跟不同行业的人沟通交流。提升一下自己的人脉和社交水平

上面的这些就是我打算在2021年完成的一些事情了,当然还有一些我现在正在做但是不知道自己能不能坚持下来的事情。如果我坚持下来了,会告诉大家的。希望大家保持关注。

如果你有什么想跟我沟通的,可以添加我的微信或者关注我的公众号关山不难越,新的一年我们一起加油吧。新的一年祝大家都身体健康,然后定下的目标都能够实现。

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入
查看原文

赞 8 收藏 0 评论 4

dreamapplehappy 关注了用户 · 1月11日

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2148

dreamapplehappy 发布了文章 · 1月11日

设计模式大冒险第五关:状态模式,if/else的“终结者”

这一篇文章是关于设计模式大冒险系列的第五篇文章,这一系列的每一篇文章我都希望能够通过通俗易懂的语言描述或者日常生活中的小例子来帮助大家理解好每一种设计模式。

今天这篇文章来跟大家一起学习一下状态模式。相信读完这篇文章之后,你会收获很多。在以后的开发中,如果遇到了类似的情况就知道如何更好地处理,能够少用ifelse语句,以及switch语句,写出更已读,扩展性更好,更易维护的程序。话不多说,我们开始今天的文章吧。

开发过程中的一些场景

我们在平时的开发过程中,经常会遇到这样一种情况:就是需要我们处理一个对象的不同状态下的不同行为。比如最常见的就是订单,订单有很多种状态,每种状态又对应着不同的操作,有些操作是相同的,有些操作是不同的。再比如一个音乐播放器程序,在播放器缓冲音乐,播放,暂停,快进,快退,终止等的情况下又对应着各种操作。有些操作在某些情况下是允许的,有些操作是不允许的。还有很多不同的场景,这里就不一一列举了。

那么面对上面说的这些情况我们应该如何设计我们的程序,才能让我们开发出来的程序更好维护与扩展,也更方便别人阅读呢?先别着急,我们一步一步来。遇到这种情况我们应该首先把整个操作的状态图画出来,只有状态图画出来,我们才可以清晰的知道这个过程中会有哪些操作,都发生了哪些状态的改变。只要我们做了这一步,然后按照状态图的逻辑去实现我们的程序;先不管代码的质量如何,至少可以保证我们的逻辑功能是满足了需求的。

生活小例子,我的吹风机

让我们从生活中的一个小例子入手吧。最近我家里新买了一个吹风机,这个吹风机有两个按钮。一个按钮控制吹风机的开关,另一个按钮可以在吹风机打开的情况下切换吹风的模式。吹风机的模式有三种,分别是热风,冷热风交替,和冷风。并且吹风机打开时默认是热风模式

如果让我们来编写一个程序实现上面所说的吹风机的控制功能,我们应该怎么实现呢?首先先别急着开始写代码,我们需要把吹风机的状态图画出来。如下图所示:

吹风机的状态图

上面的状态图已经把吹风机的各种状态都表示出来了,其中圆圈表示了吹风机的状态,带箭头的线表示状态转换。从这个状态图我们可以很直观的知道:吹风机从关闭状态到打开状态默认是热风模式,然后这三种模式可以按照顺序进行切换,然后在每一种模式下都可以直接关闭吹风机

一般的实现方式

当我们知道了整个吹风机的状态转换之后,我们就可以开始写代码了。我们先按照最直观的方式去实现我们的代码。首先我们知道吹风机有两个按钮,一个控制开关,一个控制吹风机的吹风模式。那么我们的程序中需要有两个变量来分别表示开关状态吹风机当前所处的模式。这一部分的代码如下所示:

function HairDryer() {
   // 定义内部状态 0:关机状态 1:开机状态
   this.isOn = 0;
   // 定义模式 0:热风 1:冷热风交替 2:冷风
   this.mode = 0;
}

接下来就要实现吹风机的开关按钮的功能了,这一部分比较简单;我们只需要判断当前isOn变量,如果是打开状态就将isOn设置为关闭状态,如果是关闭状态就将isOn设置为打开状态。需要注意的一点就是在吹风机关闭的情况下需要将吹风机的模式重置为热风模式

// 切换吹风机的打开关闭状态
HairDryer.prototype.turnOnOrOff = function() {
   let { isOn, mode } = this;
   if (isOn === 0) {
      // 打开吹风机
      isOn = 1;
      console.log('吹风机的状态变为:[打开状态],模式是:[热风模式]');
   } else {
      // 关闭吹风机
      isOn = 0;
      // 重置吹风机的模式
        mode = 0;
      console.log('吹风机的状态变为:[关闭状态]');
   }
   this.isOn = isOn;
   this.mode = mode;
};

在接下来就是实现吹风机的模式切换的功能了,代码如下所示:

// 切换吹风机的模式
HairDryer.prototype.switchMode = function() {
   const { isOn } = this;
   let { mode } = this;
   // 切换模式的前提是:吹风机是开启状态
   if (isOn === 1) {
      // 需要知道当前模式
      if (mode === 0) {
         // 如果当前是热风模式,切换之后就是冷热风交替模式
         mode = 1;
         console.log('吹风机的模式改变为:[冷热风交替模式]');
      } else if (mode === 1) {
         // 如果当前是冷热风交替模式,切换之后就是冷风模式
         mode = 2;
         console.log('吹风机的模式改变为:[冷风模式]');
      } else {
         // 如果当前是冷风模式,切换之后就是热风模式
         mode = 0;
         console.log('吹风机的模式改变为:[热风模式]');
      }
   } else {
      console.log('吹风机在关闭的状态下无法改变模式');
   }
   this.mode = mode;
};

这一部分的代码也不算难,但是有一些细节需要注意。首先我们切换模式需要吹风机是打开的状态,然后当吹风机是关闭的状态的时候,我们不能够切换模式。到这里为止,我们已经把吹风机的控制功能都实现了。接下来就要写一些代码来验证一下我们上面的程序是否正确,测试的代码如下所示:

const hairDryer = new HairDryer();
// 打开吹风机,切换吹风机模式
hairDryer.turnOnOrOff();
hairDryer.switchMode();
hairDryer.switchMode();
hairDryer.switchMode();
// 关闭吹风机,尝试切换模式
hairDryer.turnOnOrOff();
hairDryer.switchMode();
// 打开关闭吹风机
hairDryer.turnOnOrOff();
hairDryer.turnOnOrOff();

输出的结果如下所示:

吹风机的状态变为:[打开状态],模式是:[热风模式]
吹风机的模式改变为:[冷热风交替模式]
吹风机的模式改变为:[冷风模式]
吹风机的模式改变为:[热风模式]
吹风机的状态变为:[关闭状态]
吹风机在关闭的状态下无法改变模式
吹风机的状态变为:[打开状态],模式是:[热风模式]
吹风机的状态变为:[关闭状态]

从上面测试的结果我们可以知道,上面程序编写的逻辑是没有问题的,实现了我们想要的预期的功能。如果想看上面代码的完整版本可以点击这里浏览。

但是你能从上面的代码中看出什么问题吗?作为一个优秀的工程师,你肯定会发现上面的代码使用了太多的if/else判断,然后切换吹风机模式的代码都耦合在一起。这样会导致一些问题,首先上面代码的可读性不是很好,如果没有注释的话,想要知道吹风机模式的切换逻辑还是有点费力的。另一方面,上面代码的可扩展性也不是很好,如果我们想新增加一种模式的话,就需要修改if/else里面的判断,很容易出错。那么作为一个优秀的工程师,我们该如何重构上面的程序呢?

状态模式的介绍,以及使用状态模式重构之前的程序

接下来我们就要进入状态模式的学习过程了,首先我们先不用管什么是状态模式。我们先来再次看一下上面关于吹风机的状态图,我们可以看到吹风机在整个过程中有四种状态,分别是:关闭状态热风模式状态冷热风交替模式状态冷风模式状态。然后这四种模式分别都有两个操作,分别是切换模式切换吹风机的打开和关闭状态。(注:对于关闭状态,虽然无法切换模式,但是在这里我们也认为这种状态有这个操作,只是操作不会起作用。)

那么我们是不是可以换一种思路去解决这个问题,我们可以把具体的操作封装进每一个状态里面,然后由对应的状态去处理对应的操作。我们只需要控制好状态之间的切换就可以了。这样做可以让我们把相应的操作委托给相应的状态去做,不需要再写那么多的if/else去判断状态,这样做还可以让我们把变化封装进对应的状态中去。如果需要添加新的状态,我们对原来的代码的改动也会很小

状态模式的简单介绍

那么到这里我们来介绍一下状态模式吧,状态模式指的是:能够在对象的内部状态改变的时候改变对象的行为状态模式常常用来对一个对象在不同状态下同样操作时产生的不同行为进行封装,从而达到可以让对象在运行时改变其行为的能力。就像我们上面说的吹风机,在热风模式下,按下模式切换按钮可以切换到冷热风交替模式;但是如果当前状态是冷热风交替模式,那么按下模式切换按钮,就切换到了冷风模式了。更详细的解释可以参考State pattern

我们再来看一下状态模式的UML图,如下所示:

状态模式的UML图

可以看到,对于状态模式来说,有一个Context(上下文),一个抽象的State类,这个抽象类定义好了每一个具体的类需要实现的方法。对于每一个具体的类来说,它实现了抽象类State定义好的方法,然后Context在需要进行操作的时候,只需要请求对应具体状态类实例的对应方法就可以了

使用状态模式来重构之前的程序

接下来我们来用状态模式来重构我们的程序,首先是Context,对应的代码如下所示:

// 状态模式
// 吹风机
class HairDryer {
   // 吹风机的状态
   state;
   // 关机状态
   offState;
   // 开机热风状态
   hotAirState;
   // 开机冷热风交替状态
   alternateHotAndColdAirState;
   // 开机冷风状态
   coldAirState;

   // 构造函数
   constructor(state) {
      this.offState = new OffState(this);
      this.hotAirState = new HotAirState(this);
      this.alternateHotAndColdAirState = new AlternateHotAndColdAirState(
         this
      );
      this.coldAirState = new ColdAirState(this);
      if (state) {
         this.state = state;
      } else {
         // 默认是关机状态
         this.state = this.offState;
      }
   }

   // 设置吹风机的状态
   setState(state) {
      this.state = state;
   }

   // 开关机按钮
   turnOnOrOff() {
      this.state.turnOnOrOff();
   }
   // 切换模式按钮
   switchMode() {
      this.state.switchMode();
   }

   // 获取吹风机的关机状态
   getOffState() {
      return this.offState;
   }
   // 获取吹风机的开机热风状态
   getHotAirState() {
      return this.hotAirState;
   }
   // 获取吹风机的开机冷热风交替状态
   getAlternateHotAndColdAirState() {
      return this.alternateHotAndColdAirState;
   }
   // 获取吹风机的开机冷风状态
   getColdAirState() {
      return this.coldAirState;
   }
}

我来解释一下上面的代码,首先我们使用HairDryer来表示Context,然后HairDryer类的实例属性有state,这属性就是表示了吹风机当前所处的状态。其余的四个属性分别表示吹风机对应的四个状态实例。

吹风机有setState可以设置吹风机的状态,然后getOffStategetHotAirStategetAlternateHotAndColdAirStategetColdAirState分别用来获取吹风机的对应状态实例。你可能会说为什么要在HairDryer类里面获取相应的状态实例呢?别着急,下面会解释为什么。

然后turnOnOrOff方法表示打开或者关闭吹风机,switchMode用来表示切换吹风机的模式。还有constructor,我们默认如果没有传递状态实例的话,默认是热风模式状态。

然后是我们的抽象类State,因为我们的实现使用的语言是JavaScriptJavaScript暂时还不支持抽象类,所以用一般的类来代替。这个对我们实现状态模式没有太大的影响。具体的代码如下:

// 抽象的状态
class State {
   // 开关机按钮
   turnOnOrOff() {
      console.log('---按下吹风机 [开关机] 按钮---');
   }
   // 切换模式按钮
   switchMode() {
      console.log('---按下吹风机 [模式切换] 按钮---');
   }
}

State类主要是用来定义好具体的状态类应该实现的方法,对于我们这个吹风机的例子来说就是turnOnOrOffswitchMode。它们分别对应,按下吹风机开关机按钮的处理和按下吹风机的模式切换按钮的处理。

接下来就是具体的状态类的实现了,代码如下所示:

// 吹风机的关机状态
class OffState extends State {
   // 吹风机对象的引用
   hairDryer;
   constructor(hairDryer) {
      super();
      this.hairDryer = hairDryer;
   }
   // 开关机按钮
   turnOnOrOff() {
      super.turnOnOrOff();
      this.hairDryer.setState(this.hairDryer.getHotAirState());
      console.log('状态切换: 关闭状态 => 开机热风状态');
   }
   // 切换模式按钮
   switchMode() {
      console.log('===吹风机在关闭的状态下无法切换模式===');
   }
}

// 吹风机的开机热风状态
class HotAirState extends State {
   // 吹风机对象的引用
   hairDryer;
   constructor(hairDryer) {
      super();
      this.hairDryer = hairDryer;
   }
   // 开关机按钮
   turnOnOrOff() {
      super.turnOnOrOff();
      this.hairDryer.setState(this.hairDryer.getOffState());
      console.log('状态切换: 开机热风状态 => 关闭状态');
   }
   // 切换模式按钮
   switchMode() {
      super.switchMode();
      this.hairDryer.setState(
         this.hairDryer.getAlternateHotAndColdAirState()
      );
      console.log('状态切换: 开机热风状态 => 开机冷热风交替状态');
   }
}

// 吹风机的开机冷热风交替状态
class AlternateHotAndColdAirState extends State {
   // 吹风机对象的引用
   hairDryer;
   constructor(hairDryer) {
      super();
      this.hairDryer = hairDryer;
   }
   // 开关机按钮
   turnOnOrOff() {
      super.turnOnOrOff();
      this.hairDryer.setState(this.hairDryer.getOffState());
      console.log('状态切换: 开机冷热风交替状态 => 关闭状态');
   }
   // 切换模式按钮
   switchMode() {
      super.switchMode();
      this.hairDryer.setState(this.hairDryer.getColdAirState());
      console.log('状态切换: 开机冷热风交替状态 => 开机冷风状态');
   }
}

// 吹风机的开机冷风状态
class ColdAirState extends State {
   // 吹风机对象的引用
   hairDryer;
   constructor(hairDryer) {
      super();
      this.hairDryer = hairDryer;
   }
   // 开关机按钮
   turnOnOrOff() {
      super.turnOnOrOff();
      this.hairDryer.setState(this.hairDryer.getOffState());
      console.log('状态切换: 开机冷风状态 => 关闭状态');
   }
   // 切换模式按钮
   switchMode() {
      super.switchMode();
      this.hairDryer.setState(this.hairDryer.getHotAirState());
      console.log('状态切换: 开机冷风状态 => 开机热风状态');
   }
}

由上面的代码我们可以看到,对于每一个具体的类来说,都有一个属性hairDryer,这个属性用来保存吹风机实例的索引。然后就是对应turnOnOrOffswitchMode方法的实现。我们可以看到在具体的类中我们设置hairDryer的状态是通过hairDryer实例的setState方法,然后获取状态是通过hairDryer对应的获取状态的方法。比如:this.hairDryer.getHotAirState()就是获取吹风机的热风模式状态。

在这里我们可以说一下为什么要在HairDryer类里面获取相应的状态实例:因为这样不同的状态类之间相当于解耦了,它们不需要在各自的类中依赖对应的状态,直接从hairDryer实例上获取对应的状态实例就可以了。减少了类之间的依赖,使我们代码的可维护性变的更好了

接下来就是需要测试一下我们上面通过状态模式重构后的代码有没有实现我们想要的功能,测试的代码如下:

const hairDryer = new HairDryer();
// 打开吹风机
hairDryer.turnOnOrOff();
// 切换模式
hairDryer.switchMode();
// 切换模式
hairDryer.switchMode();
// 切换模式
hairDryer.switchMode();
// 关闭吹风机
hairDryer.turnOnOrOff();
// 吹风机在关闭的状态下无法切换模式
hairDryer.switchMode();

输出的结果如下所示:

---按下吹风机 [开关机] 按钮---
状态切换: 关闭状态 => 开机热风状态
---按下吹风机 [模式切换] 按钮---
状态切换: 开机热风状态 => 开机冷热风交替状态
---按下吹风机 [模式切换] 按钮---
状态切换: 开机冷热风交替状态 => 开机冷风状态
---按下吹风机 [模式切换] 按钮---
状态切换: 开机冷风状态 => 开机热风状态
---按下吹风机 [开关机] 按钮---
状态切换: 开机热风状态 => 关闭状态
===吹风机在关闭的状态下无法切换模式===

根据上面的测试结果可以知道,我们重构之后的代码也完美地实现了我们想要的功能。使用状态模式重构后的完整版本可以点击这里浏览。那么接下来我们就来分析一下,使用状态模式与第一种不使用状态模式相比有哪些优势和劣势。

使用状态模式的优势有以下几个方面:

  • 将应用的代码解耦,利于阅读和维护。我们可以看到,在第一种方案中,我们使用了大量的if/else来进行逻辑的判断,将各种状态和逻辑放在一起进行处理。在我们应用相关对象的状态比较少的情况下可能不会有太大的问题,但是一旦对象的状态变得多了起来,这种耦合比较深的代码维护起来就很困难,很折磨人。
  • 将变化封装进具体的状态对象中,相当于将变化局部化,并且进行了封装。利于以后的维护与拓展。使用状态模式之后,我们把相关的操作都封装进对应的状态中,如果想修改或者添加新的状态,也是很方便的。对代码的修改也比较少,扩展性比较好。
  • 通过组合和委托,让对象在运行的时候可以通过改变状态来改变自己的行为。我们只需要将对象的状态图画出来,专注于对象的状态改变,以及每个状态有哪些行为。这让我们的开发变得简单一些,也不容易出错,能够保证我们写出来的代码质量是不错的。

使用状态模式的劣势:

  • 当然使用状态模式也有一点劣势,那就是增加了代码中类的数量,也就是增加了代码量。但是在绝大多数情况下来说,这个算不上什么太大的问题。除非你开发的应用对代码量有着比较严格的要求。

状态模式的总结

通过上面对状态模式的讲解,以及吹风机小例子的实践,相信大家对状态模式都有了很深入的理解。在平时的开发工作中,如果一个对象有很多种状态,并且这个对象在不同状态下的行为也不一样,那么我们就可以使用状态模式来解决这个问题。使用状态模式可以让我们的代码条理清楚,容易阅读;也方便维护和扩展

为了验证你的确已经掌握了状态模式,这里给大家出个小题目。还是以上面的吹风机为例子,如果现在吹风机新增加了一个按钮,用来切换风速强度的大小。默认风速的强度是弱风,按下按钮变为强风。现在你能修改上面的代码,然后实现这个功能吗,赶快动手试试吧~

文章到这里就结束了,如果大家有什么问题和疑问欢迎大家在文章下面留言,或者在dreamapplehappy/blog提出来。也欢迎大家关注我的公众号关山不难越,获取更多关于设计模式讲解的内容。

下面是这一系列的其它的文章,也欢迎大家阅读,希望大家都能够掌握好这些设计模式的使用场景和解决的方法。如果这篇文章对你有所帮助,那就点个赞,分享一下吧~

参考链接:

查看原文

赞 31 收藏 19 评论 3

dreamapplehappy 赞了文章 · 2020-12-17

思否独立开发者丨@主线程:如果全身心投入1年,但是收入是0,你还愿意做独立开发者吗?

image

独立项目名称:主线程

月收入(选答):暂时0

思否社区ID:dreamapplehappy


今天我们采访到的独立开发者是dreamapplehappy,在杭州读完大学之后,他选择留在杭州这个美丽的城市。

他先后在三家创业公司工作过,并且都是负责前端。现在在一家比较稳定的独角兽公司工作。

最近他在忙两个事情:

  • 一个方向是关于产品,学习如何从零到一开发一款产品,然后慢慢优化,发展壮大
  • 另一个方向是关于技术,想深入的把通用的知识如设计模式,数据结构与算法,以及正则表达式相关的知识在深入的学习整理一下,然后看看能不能写一些文章或者教程帮助大家更好的学习。最近在写的一个系列是设计模式大冒险系列

想让这个世界因为自己的存在而变得有一点点不一样,他选择成为一名独立开发者。

他认为独立开发者有足够的自由,不受约束,可以自己把握产品的方向,开发的节奏;更加的灵活,小巧,有更多的成就感和满足感。

最主要的是你是在创造,是内在驱动的,是充满热情的,富有活力的,包容的,不放弃的。这些都是你在为他人工作的时候很难感受到的。

作为一个独立开发者,更接近创业,可以跳出自己的舒适圈,与不同的人沟通协作和交流,拓宽自己的思维,让自己站得更高,看得更远。

主线程

立项日期:19年4月份

项目背景:dreamapplehappy所在的创业公司因为产品失败,团队就解散了。然后自己暂时也不想马上就找下一份工作,觉得是不是可以做点什么事情,折腾一下。当时我女朋友也离职在家,她在离职的这段时间还是保持着自律,然后为了督促自己学习,她还建了一个学习打卡的微信群。然后每天监督大家学习,完成计划。然后统计群里每一位成员是否打卡,以及打卡的天数。

当时她开始时用的是Excel,我知道后便问她为什么不选择市面上已经有的一些解决方案。她说那些都不太满足她的需求;然后我就提议说要不我们来开发一个吧,反正我们现在有很多时间,暂时也不用考虑生活的问题。于是主线程这个项目就诞生了。

面向群体:前期针对18-30岁考生,如考研、考证、考公、考编、考四六级等人群(这类用户目标明确,使用产品粘性大,付费意愿相对较强),后期扩展至职场和K12人群

  • 愿景:让成为理想的自己变得简单易行
  • 产品:目前是一款学习打卡类小程序,后续结合服务号&微信群运营,之后会考虑开发APP以及Web端的应用
  • 核心价值:帮助用户形成“设定目标-计划任务-坚持打卡-达成目标-经验变现”的学习成长闭环
  • 小程序功能:主线任务、番茄专注、打卡圈子、想法广场、数据分析
  • 未来构想:搭建成长平台,建立内容互动社区(UGC+PGC),解决学习成长过程中的各类问题,打造学习界的Keep!

1、如何做的第一版产品?

列出功能点:因为这个产品也是我自己使用的工具,所以我即是用户,也是开发者;所以我们第一步是先把需求整理了一下,把关键的需求记录下来。

市面上产品的分析:我们也下载了一些市面上大家用的比较多的,关于组队学习打卡、监督学习、任务管理和学习时间记录分析的产品。看看对于上面我们列出的功能,市面上的通用解决方案是怎样的,是不是比较好?没有没有可以优化的地方》总之把这些产品的优缺点都做一个记录。

画原型图:我们使用最原始的方案,把产品的原型画在纸上。相比于电子版纸质版感觉更灵活方便。现在记录这个产品的原型图的纸质笔记本还在我的书桌旁边,每逢看到这个本子,都会想起来当时开发主线程的种种美好。

image

开发:我负责前端的开发,我女朋友负责后端开发。我们选择的开发平台是微信小程序。因为微信小程序开发比较方便,而且基于微信,比较容易分享和传播。

我记得第一个版本我们两周就开发完了。把我们想要的最最基本的功能做了出来。

2、独立开发过程中遇到过哪些困难?最难搞定的是什么?

  • 缺少设计,需要自己花费大量的时间去看一些设计师设计的类似产品,设计产品的交互方式以及交互的动效。**
  • 缺少运营,需要自己在开发之外抽出一定的时间去相关的平台运营,宣传。
  • 由于小程序平台的限制,有些功能我们想做但是因为受限于小程序提供的有限功能,所以不得不换种方式。但是替代的方案效果一般来说不是很好。
  • 一些其它的比如小程序审核,公司注册,域名备案,等等。**

最难搞定的其实是自己对自己产品的信心,我们在开发的过程中有时也会突然感觉自己的产品好像不是很好,觉得它没有竞争力,开发出来不会有人用。在这种情绪下我们的士气会比较低落,我们的开发进度就会比较慢。好在我们后来慢慢的找回了开发产品的自信,进度也就慢慢变正常了。

3、项目目前取得了哪些成就?项目为你带来了什么?

  • 注册使用的用户已经一万多了
  • 日活最多的时候能够达到600+
  • 得到了一些投资人的肯定
  • 收到了很多用户的夸奖与认可

项目虽然没有给我们带来金钱上的回报,但是带给我们精神和思想上的收益却是金钱买不来的,同时也增加了我的技能树。通过从0到1完成这个项目,我自己对待自己的生活和工作都有了新的认识。我的思维变得开阔,不再是以前那个只知道开发,学习技术的工程师。遇到问题思考的方式也会有一些变化,会站在更高一层去看待技术,产品之间的关系。也为我下一次创业积累了很多宝贵的财富,我相信如果我下次创业会比这次更成功一些的。

4、你的商业模式是什么?是如何增长的?

商业模式:搭建成长平台,建立内容互动社区(UGC+PGC),解决学习成长过程中的各类问题,打造学习界的 Keep!盈利方式依赖会员,付费的内容以及广告。

增长:一方面来自我们在相关平台的推广,一方面来自用户自己的打卡分享,以及圈子的邀请等

5、近阶段项目有哪些更新,未来会做什么变动?

近阶段项目一直在维护中,功能上暂时没有进行迭代更新。未来应该会继续维护下去,如果时间允许,我们会继续迭代新的功能。

6、如果项目重来一次你会做哪些改变?

先注册一个公司,把产品上线需要的一些条件都准备好。

尽快开发出一个最小可行性版本,然后尽早跟用户见面,多收集用户的反馈。保持快速的迭代,不断地优化产品的体验。跟主流程不相关的功能都不要添加,要持续打磨产品的核心功能。

个人相关问题

1、推荐你最喜欢的一款产品 / 游戏 / App?并说明原因

珍新闻(原锤子阅读)

正如APP的slogan那样,看少一点,看好一点。现在我发觉身边的人对于碎片化的内容兴趣比较高,比较喜欢看短视频,短的资讯。这会让我们感觉自己好像知道很多,但是这些都是不太成体系的东西,我们自己没有把这些知识归纳到自己的体系中。也就不能够很好的消化这些零碎的知识。

珍新闻里面的内容相对不是那么碎片化,相对比较完整。文章的内容质量一般也不错。重要的是这里面的大多数文章都能够将一个事情的来龙去脉讲解清楚,有深度,不是泛泛而谈。我也很喜欢那种花费十几二十分钟,甚至半个小时读完一篇文章的快感。

推荐给大家,希望对大家有所帮助

2、分享一下你的技术栈和你日常的工作流?

技术栈:

  • JavaScript
  • Node.js
  • Vue.js
  • 小程序

工作流:

  • 资讯:Hacker News
  • 阅读:珍新闻
  • 产品:PMCAFF
  • 开发工具:Webstrom

3、对独立开发者或编程初学者有什么建议?

对独立开发者:

  • 开始之前先确保自己能够在开发产品的过程中维持生活
  • 对自己的产品保持热爱,充满信心。
  • 快速迭代,多获取用户的意见和建议,持续改进产品
  • 如果可以的话,寻找合伙人,让专业的人干专业的事情
  • 保持一个好心情,和一个正常的工作习惯

对编程初学者:

  • 编程语言,开发工具选择一个合适的就好,不要陷入对语言和开发工具的争执中。
  • 要多实践,要多写博客整理回顾自己学习的知识
  • 基础知识一定要掌握好,深厚的基础知识会给你的学习带来很多便利
  • 身体很重要,学会劳逸结合,学会好好对待自己的身体健康

4、生活中有什么爱好?有什么个人的特别的工作习惯吗?

爱好:看电影,学习,弹吉他

特别的工作习惯:先把问题想清楚,考虑好然后在动手开发

5、你对国内的独立开发者环境(云厂商、数字化营销服务商 )有什么意见和建议?

希望能给独立开发者一个好的平台支持,提供相关的工具或者资源帮助开发者快速完善自己的产品。

6、聊聊你的思否的看法或对国内技术社区的看法

我在上大学的时候就注册了思否账号,感觉这是一个不错的平台。确实帮助了很多开发者解决自己遇到的一些开发的问题。思否的文章质量也都挺不错的,也能让一些开发者学习到很多知识,希望思否越来越好。

独立开发者寄语

如果想跟我讨论产品或者交流技术可以关注我的公众号「关山不难越」或者关注我的思否账号dreamapplehappy,期待与大家的交流。

也欢迎你来使用主线程

image

独立开发者支持计划-1.png

该内容栏目为「SFIDSP - 思否独立开发者支持计划」。为助力独立开发者营造更好的行业环境, SegmentFault 思否社区作为服务于开发者的技术社区,正式推出「思否独立开发者支持计划」,我们希望借助社区的资源为独立开发者提供相应的个人品牌、独立项目的曝光推介。

有意向的独立开发者或者独立项目负责人,可通过邮箱提供相应的信息(个人简介、独立项目简介、联系方式等),以便提升交流的效率。

联系邮箱:pr@segmentfault.com


image

二维码过期添加思否小姐姐拉你入群
image.png
查看原文

赞 9 收藏 2 评论 0

dreamapplehappy 关注了用户 · 2020-12-16

宗恩 @yzn

关注新科技

联系邮箱 yzn@sifou.com

交流微信:yyuuuuusjjjd

关注 47

dreamapplehappy 发布了文章 · 2020-12-09

设计模式大冒险第四关:单例模式,如何成为你的“唯一”

image

这一篇文章是关于设计模式大冒险系列的第四篇文章,这一系列的每一篇文章我都希望能够通过通俗易懂的语言描述或者日常生活中的小例子来帮助大家理解好每一种设计模式。今天这篇文章来跟大家一起学习一下单例模式。相信读完这篇文章之后,你肯定会有所收获的。

关于单例模式,这应该是设计模式中最简单的一种了。大家如果学习过设计模式,可能很多设计模式长时间不用就忘记了,但是对于单例模式来说,你肯定不会忘记。因为它的理论知识比较简单,实践起来也很方便

但是,你真的会正确的使用单例模式吗?你知道单例模式在什么情况下使用是合适的,什么情况下使用会造成很多麻烦吗?还是你只是把它当做一个全局变量去使用,只是因为这样开发很方便,不用写很多的代码。今天这篇文章我们就来一起好好学习一下单例模式。让我们开始吧。

单例模式的介绍

首先我们先来看一下单例模式的定义是什么。所谓的单例模式,就是指对于一个具体的类来说,它有且只有一个实例,这个类负责创建唯一的实例,并且对外提供一个全局的访问接口

单例模式的UML类图可以用下图表示:

image

那么我们为什么要使用单例模式呢?举一个生活中的场景,在平时你过马路的时候,给你信号提示你能不能穿过马路的交通信号灯是不是只有一个?因为在这种情况下,如果同时有两个信号灯的话,你是不知道该不该在此时穿过马路的

所以类比到我们的软件开发中,也是这么一个道理。在一个系统中,某种用途的实例会存在唯一的一个。这个实例可能用来保存应用中的一些状态,或者执行某些任务。比如在前端开发中,我们常常会使用一些应用的状态管理库,比如Vuex或者Redux。那么在我们的应用中,对于管理状态的实例也只能有一个,如果有多个的话就会让应用的状态出现问题,从而导致应用发生一些错误。

单例模式的实现

接下来我们来看一下单例模式是如何实现的。通过上面的UML类图,我们可以知道,对于一个类来说,我们需要一个静态变量来保存实例的引用,还需要对外提供一个获取实例的静态方法。如果使用 ES6 的类的语法来实现的话,可以简单的用下面的代码来表示:

class Singleton {
    // 类的静态属性
    static instance = null;

    // 类的静态方法
    static getInstance() {
        if (this.instance === null) {
            this.instance = new Singleton();
        }
        return this.instance;
    }
}

const a = Singleton.getInstance();
const b = Singleton.getInstance();

console.log(a === b); // true

上面的代码还是比较简单的,相信大家看一下就知道怎么实现了。需要注意的一点是,在类的静态方法中,this指的是类,而不是实例

下面我们再使用函数的方式来实现一次:

const Singleton = (function() {
    let instance;

    // 初始化单例对象的方法
    function initInstance() {
        return {};
    }

    return {
        getInstance() {
            if (instance === null) {
                instance = initInstance();
            }
            return instance;
        },
    };
})();

const a = Singleton.getInstance();
const b = Singleton.getInstance();

console.log(a === b);   // true

上面这两种方法的实现都是差不多的,你可以根据自己的喜好选择不同的实现方式。

多线程环境中的单例模式

作为Web前端开发者来说,因为我们使用的开发语言基本上是JavaScript,又因为JavaScript是一种单线程语言,所以我们一般不会遇到在多线程环境中使用单例模式会遇到的一些问题。

那么我们如果在多线程的环境中使用单例模式需要注意什么呢?首先在单例还没有初始化的时候,如果有多个线程访问创建单例模式的代码,在没有做额外处理的情况下,就有可能会创建多个单例

当然也有解决的方法,一种方法就是我们在类初始化的时候就把单例生成了,这样以后通过获取单例的接口获取到的都是最开始生成的那个单例。但是这样就失去了延时初始化单例的好处。如果单例的初始化需要花费的资源或者时间比较少,这种方法是可以的。反之,这样做有就有一些浪费了。因为可能在整个应用的运行过程中,这个单例一次也没有被使用过

另一种方式就是在创建单例的时候需要加锁,保证同时只能有一个线程在创建单例。这样的话我们就保证了创建的单例是唯一的。当然具体的操作还跟实现单例模式选择的语言有关系,这里就不在深入讨论了。

单例模式的适用场景和优势

单例模式适合用在这样的场景中:系统中需要一个唯一的对象去控制、管理和分享系统的状态,或者执行某一个特定的任务又或是实现某一个具体的功能。在我们的前端开发中,最常见的就是应用的状态管理对象,比如 VuexRedux。又或者是打印日志的对象,或者是某一个功能插件等等。总之单例模式在我们平时的开发中还是比较常见的。

那么单例模式的优势有哪些呢?下面简单列举了一些:

  • 全局只有一个实例,提供统一的访问与修改,保证状态功能的一致性
  • 简单、方便,容易实现
  • 延迟的初始化,只有在需要的时候才去初始化对象

单例模式的劣势

虽然单例模式的优势很突出,但是它的缺点可是一点都不少,甚至有些开发者觉得它是反模式的。所以我们使用单例模式的时候一定要好好思考一下,确定是不是必须要使用单例模式。因为单例模式的不恰当使用会给整个应用的测试,开发和维护带来很大的困难。我们接下来就来看看单例模式有哪些缺点。

单例模式的滥用会造成跟全局变量一样的一些问题

比如会增加代码的耦合性,因为单例模式全局都是可以访问到的,那么我们就很有可能在很多个地方使用这个唯一的对象,这样也就造成了代码的耦合。

因为程序中使用到这个单例对象的地方都可以对全局的状态进行修改,所以一旦程序在这里出现了问题,你可能要在很多个地方进行排查,这就增加了调试和排查问题的难度。

单例模式给测试带来了很多麻烦

为什么说单例模式对测试来说是一个灾难呢?因为如果代码中使用了单例,那么我们需要在进行代码测试的时候,提前把单例初始化好。这导致了我们不能够在单例没有初始化好的时候对代码进行单元测试。

而且因为单例模式产生的实例只有一个,这就导致了对相同代码进行多次测试的时候容易出现问题,因为实例的状态很可能在上一次测试的时候发生了改变,从而导致了下一次测试的失败或者异常。

所以说单例模式增加了测试的难度与复杂度,增加了测试代码的工作量。

单例模式违背了软件设计的单一职责原则

这个比较容易理解,因为一般情况下,对于一个类来说它只负责这个类的实例具有什么功能;但是对于单例模式来说,单例模式的类还需要负责只能够产生一个实例。这违背了软件设计的单一职责原则,类应该只负责其实例的具体功能,而不应该对类产生的实例个数负责。

但是对于这个缺点来说,大家可能会有不同的看法。显而易见的是这样做确实更加方便,设计实现上也相对简单一些。

单例模式隐藏了它所需要的依赖

对于一般的类来说,如果我们的类依赖了其它的类,一般情况下,我们可以通过类的构造函数将依赖的类显式的表示出来。这样我们在初始化具体的类的实例的时候就知道这个类需要那些依赖。

但是对于单例模式来说,它把它的依赖封装在内部,对于外部的使用者来说它是一个黑盒。使用者并不知道初始化这个单例需要那些依赖,所以很容易在初始化单例的时候把单例所需要的依赖忘记掉,进而导致单例初始化失败。

有时就算我们知道了初始化单例需要那些依赖,但是这些依赖也许是有先后的顺序的。我们也很容易在导入和使用依赖的时候把顺序搞错了,从而导致单例的初始化出现问题。

单例模式的总结

从上面的内容我们已经知道单例模式是一把双刃剑,所以你在使用的时候一定要考虑清楚。先从场景的需求上考虑,是不是一定要使用单例模式才能够解决当前的问题,有没有其它的方案。如果一定要使用单例模式的话,如何规范单例模式的使用,如何在程序的开发,可维护性,可拓展性以及测试的简易性上做好平衡,是一个值得考虑的问题

文章到这里就结束了,如果大家有什么问题和疑问欢迎大家在文章下面留言,或者在这里提出来。也欢迎大家关注我的公众号关山不难越,获取更多关于设计模式讲解的内容。

下面是这一系列的其它的文章,也欢迎大家阅读,希望大家都能够掌握好这些设计模式的使用场景和解决的方法。如果这篇文章对你有所帮助,那就点个赞,分享一下吧~

参考链接:

查看原文

赞 6 收藏 6 评论 0

dreamapplehappy 关注了问题 · 2020-11-21

javascript中获取的视频是数组形式的,如何进行播放?或者如何进行转换后播放?

视频保存在aws s3中,和oss有些类似。
image.png
现在想获取s3中的视频并进行播放,但调取接口,返回的视频格式是数组形式的,如下:
image.png
image.png
请问,这样格式的视频我将如何进行播放?
在网上查到的都是nodejs的相关内容,我需要的是网页上的javascript的处理方式。

关注 5 回答 2

dreamapplehappy 发布了文章 · 2020-11-12

设计模式大冒险第三关:工厂模式,封装和解耦你的代码

image

这篇文章是关于设计模式系列的第三篇文章,这一系列的每一篇文章都会通过生活中的小例子以及一些简单的比喻让大家明白每一个设计模式要解决的是什么问题,然后通过什么方式解决的。希望大家在看过每篇文章之后都能够理解文章中讲解的设计模式,然后有所收获。话不多说,让我们开始今天的冒险吧。

工厂模式的第一印象

对于初次听说这个设计模式的同学来说,你们的第一印象是什么呢?既然是工厂模式,那么肯定跟工厂的一些功能或者行为有关系。那么工厂都有哪些功能和行为呢?首先工厂收集原始材料,然后将原始的材料进行加工,处理,设计之后就变成了一个完整的产品或者部件。

这个过程对于产品的销售店,或者用户来说是不可见的。对于商家来说如果你想卖这个产品,你只需要去跟厂家沟通买一批这样的产品就行了。那对于用户来说,你想使用这个产品,只需要到卖这个产品的店里把它买回来就好了。

所以根据上面的推论,类比到代码中我们可以得出一些初步的结论:工厂模式封装了对象的创建过程,把创建和使用对象的过程进行了分离,解耦代码中对具体对象创建类的依赖。让代码更好维护,更方便扩展

当然,如果想要知道这个设计模式是如何封装了对象的创建过程,并且减少了对具体类的依赖的话,我们还是要实践一下,通过一些例子或者开发中的场景学习如何使用好这个设计模式。那就让我们开始吧。

简单工厂

根据对代码封装抽象的程度,工厂模式的实现方式有三种,它们分别是:简单工厂工厂方法,以及抽象工厂

我们首先来学习和了解一下简单工厂吧,假如你现在接手了一个生产蛋糕的程序,程序的部分代码如下:

// 泡芙蛋糕
const PUFF_CAKE = "PUFF_CAKE";
// 奶酪蛋糕
const CHEESE_CAKE = "CHEESE_CAKE";

class PuffCake {
  constructor() {
    this.name = "(泡芙蛋糕)";
  }
}
class CheeseCake {
  constructor() {
    this.name = "(奶酪蛋糕)";
  }
}

class CakeMaker {
  constructor(type) {
    if (type === PUFF_CAKE) {
      this.cake = new PuffCake();
    } else {
      this.cake = new CheeseCake();
    }
  }

  // 搅拌原料
  stirIngredients() {
    console.log(`开始搅拌${this.cake.name}`);
  }

  // 倒入模具中
  pourIntoMold() {
    console.log(`将${this.cake.name}倒入模具`);
  }

  // 烘烤蛋糕
  bakeCake() {
    console.log(`开始烘焙${this.cake.name}蛋糕`);
  }
}

// 制作蛋糕
const cakeMaker = new CakeMaker(PUFF_CAKE);
cakeMaker.stirIngredients();
cakeMaker.pourIntoMold();
cakeMaker.bakeCake();

现在这个制作蛋糕的程序需要新添加一种海绵蛋糕,你要怎么去修改这个程序,让它能够支持生产海绵蛋糕呢?也许你的第一反应就是将CakeMaker构造函数进行修改,新增加一个类型的判断,比如像下面这样:

// ...
constructor(type) {
    if (type === PUFF_CAKE) {
      this.cake = new PuffCake();
    } else if (type === CHEESE_CAKE) {
      this.cake = new CheeseCake();
    } else {
      this.cake = new SpongeCake();
    }
}
// ...

这时,我们可以思考一下,虽然上面的方法的确可以帮助我们实现添加海绵蛋糕的功能,但是这样做会有一些问题。会有哪些问题呢?

image

首先,如果按照这个方式的话,我们以后只要添加新种类的蛋糕或者移除不受欢迎的蛋糕就必须要修改CakeMaker构造函数

这样做实在不是一个好的方案,而且每当我们在CakeMaker中新增加一个具体的蛋糕类的话,就相当于给这个类新增加了一个依赖。这样我们CakeMaker的依赖会越来越多,任何一个依赖类发生改变都可能导致我们的CakeMaker类不能够正常工作,出错的几率大大增加

那么我们应该如何修改呢?我们应该减少CakeMaker类中对具体类的依赖,然后将生成蛋糕种类的过程从CakeMaker中移除。我们可以这样做:

// ...
// 封装蛋糕的创建过程
function cakeCategoryMaker(type) {
  let cake;
  if (type === PUFF_CAKE) {
    cake = new PuffCake();
  } else if (type === CHEESE_CAKE) {
    cake = new CheeseCake();
  } else {
    cake = new SpongeCake();
  }
  return cake;
}
// ...
class CakeMaker {
  constructor(type) {
    this.cake = cakeCategoryMaker(type);
  }
  // ...
}
// ...

当你看完了上面的代码,你可能会说,这不只是把代码从一个地方移到了另一个地方,好像没有发生什么根本的变化呀。的确是这样,但是我们来看一下,一旦我们把生成蛋糕种类的代码移到外面,我们的CakeMaker是不是减少了对具体蛋糕类的依赖。现在对于CakeMaker类来说,它的依赖只有cakeCategoryMakerCakeMaker不需要管你给我的蛋糕是什么类型的,我只负责对其进行加工制作,并不关心蛋糕的原料和种类。

而且,我们的cakeCategoryMaker还可以被其它的蛋糕加工程序所共享;如果以后还需要增加或者移除蛋糕种类的话,我们只需要在这一个地方修改就可以了。而不需要在每个加工蛋糕的代码中分别进行修改。这就是一个很好的编码习惯。

image

在实际的开发中,我们的程序中可能存在需要根据不同场景创建不同类型对象的功能,但是这些对象具有同样的属性和接口,或者需要根据不同的数据源创建相同的对象。那么这个时候,我们就可以把这一部分的逻辑抽离出来,然后在全局中进行使用

这就是我们所说的简单工厂了,当然严格意义上来说,简单工厂不算是一个真正的设计模式。但是它很有用,它封装了根据不同类型来创建不同对象的过程,将我们的程序进行了解耦,这样便于程序的维护和扩展。是一个不错的编程习惯和技巧,值得我们学习和使用

工厂方法

接下来我们来了解并学习工厂方法这种更高一级别的封装和抽象。在实际的开发中我们有时会写一些通用的组件,方便我们后续的业务开发使用。假如下面两个组件是已经开发好的组件:

class Toast {
  constructor(text) {
    this.text = text;
  }
  show() {
    console.log(`toast show: ${this.text}`);
  }
  hide() {
    console.log("toast hide");
  }
}

class Modal {
  constructor(text) {
    this.text = text;
  }
  show() {
    console.log(`modal show: ${this.text}`);
  }
  hide() {
    console.log("modal hide");
  }
}

const toast = new Toast("hello");
toast.show();
toast.hide();
// modal
const modal = new Modal("world");
modal.show();
modal.hide();

上面关于组件的代码是没有什么太大问题的,但是我们再仔细思考一下也许会觉得好像这两个组件都有showhide这两个方法。那么这就相当于是重复代码了,一般情况下如果出现了重复的代码那么说明我们还是有优化的地方的。

image

并且如果在不改变现有的思路的情况下,我们要再开发一个新的提示类型的组件的话,还是会在代码中重复这两个方法。那么有没有办法解决这个问题呢?当然有办法了,我们知道这种类型的组件都有showhide这两个方法。那么我们可以通过继承的方式从父类那里继承这两个方法,关于组件的具体创建过程我们可以在子类中进行实现

具体实现的代码如下:

class CustomComponent {
  createComponent() {
    // TODO 需要被子类实现
  }
  show() {
    this.concreteComponent = this.createComponent();
    console.log(
      `this ${this.concreteComponent.name} show: ${this.concreteComponent.text}`
    );
  }
  hide() {
    console.log(`this component hide`);
  }
}

class ToastComponent extends CustomComponent {
  createComponent() {
    return {
      name: "toast",
      text: "hello",
    };
  }
}

class ModalComponent extends CustomComponent {
  createComponent() {
    return {
      name: "modal",
      text: "world",
    };
  }
}

const toast = new ToastComponent();
toast.show();
toast.hide();
const modal = new ModalComponent();
modal.show();
modal.hide();

这个解决方案的思路就是:我们在父类中把子类的一些通用的操作进行实现。然后具体组件的创建细节交给子类去解决。那么这样做就相当于把组件创建的过程进行了封装,父类不需要知道这个组件是如何创建的,被谁创建的。但是这个子类组件已经继承了父类的那些方法,所以可以直接使用父类的方法进行展示和隐藏。

image

因为在JavaScript中暂时还没有实现抽象类的功能,所以我们上面代码中的CustomComponent类从严格意义上说还不是一个抽象的父类。不过关系不是很大,思路和功能还是能够实现的。

那我们来总结一下工厂方法的特性:

  • 父类通过一个抽象的方法封装了对象的创建过程,对象的创建过程被延迟到子类中进行创建
  • 子类因为是从父类继承而来,所以可以使用父类已经实现好的方法
  • 工厂方法将我们的代码进行了解耦,创建组件的时候不需要再给父类传递对象的类型,由子类决定创建的对象的类型

抽象工厂

接下来我们来讲解一下抽象程度最高的抽象工厂,看过上一篇文章👉设计模式大冒险第二关:装饰者模式,煎饼果子的主场的同学应该知道,由于上次你给煎饼果子的老板帮了个忙。所以他知道你的编程水平不错,今天又来找你帮忙啦。

这次的问题是这样的,老板说他那边有一个获取煎饼果子原材料的程序,但是最近附近的一个菜市场提供的蔬菜不是很新鲜。所以想换一个菜市场去买原材料,但是更换菜市场的话,之前程序一些统计的数据就不准确了,所以需要让你帮忙修改一下现有的程序。现在的程序部分代码如下:

class PanCakeMaterials {
  constructor(vegetableMarketName) {
    this.vegetableMarketName = vegetableMarketName;
  }

  getEgg() {
    if (this.vegetableMarketName === "VEGETABLE_MARKET_NAME_A") {
      return "a_market_egg";
    }
    if (this.vegetableMarketName === "VEGETABLE_MARKET_NAME_B") {
      return "b_market_egg";
    }
  }

  // ... 其它的原料
}

const panCakeMaterials = new PanCakeMaterials("VEGETABLE_MARKET_NAME_A");
console.log(panCakeMaterials.getEgg());  // a_market_egg

我们可以看到现在这个获取原材料的程序虽然实现了获取原材料的功能,但是现在的扩展性太差。如果添加了新的菜市场或者移除不使用的菜市场的话,就需要修改程序。所以又到了你大展身手的时候了,巧的是你刚刚学习完工厂模式的抽象工厂这个解决方案。所以你知道该如何重构现在的代码了。

首先原来的代码太依赖我们给的类型值了,如果输入的类型值有问题的话,那么整个获取原材料的程序就没有办法运行起来。所以我们需要将每种食材获取的过程封装起来,由一个VegetableMarketProvider类来负责,然后对于PanCakeMaterials来说,我们只需要将VegetableMarketProvider子类的实例化对象当做传给PanCakeMaterials类的参数进行初始化就可以了

实现的代码如下所示:

class VegetableMarketProvider {
  provideEgg() {}
}

class FirstVegetableMarketProvider extends VegetableMarketProvider {
  provideEgg() {
    return "a_market_egg";
  }
}

class SecondVegetableMarketProvider extends VegetableMarketProvider {
  provideEgg() {
    return "b_market_egg";
  }
}

class PanCakeMaterials {
  constructor(vegetableMarketProvider) {
    this.vegetableMarketProvider = vegetableMarketProvider;
  }

  getEgg() {
    return this.vegetableMarketProvider.provideEgg();
  }

  // ... 其它的原料
}

const firstVegetableMarketProvider = new FirstVegetableMarketProvider();
const secondVegetableMarketProvider = new SecondVegetableMarketProvider();
const panCakeMaterials = new PanCakeMaterials(firstVegetableMarketProvider);
console.log(panCakeMaterials.getEgg());  // a_market_egg
const secondPanCakeMaterials = new PanCakeMaterials(
  secondVegetableMarketProvider
);
console.log(secondPanCakeMaterials.getEgg());  // b_market_egg

我们来分析一下优化后的代码,首先我们写了一个抽象的VegetableMarketProvider类,这个类里面的所有方法也都是抽象的,需要由子类去实现具体的方法。每一个子类对应一个具体的菜市场,这样的话每一个菜市场提供的原材料也就知道是什么了

image

对于PanCakeMaterials类来说,我们不再传递一个表示菜市场类型的字符串了;取而代之的是,传递一个菜市场的实例。这样的话当我们初始化PanCakeMaterials的时候,对应的菜市场也就确定了,那对应的原材料也就确定了

这样的好处有哪些呢?首先如果我们要更换菜市场,再也不需要改变PanCakeMaterials类的代码了,只需要更换传给PanCakeMaterials类的参数就可以了。然后如果需要添加新的菜市场的话,只需要新加一个VegetableMarketProvider的子类,在子类里面实现相应原材料的获取。这就体现了我们程序设计中的一个原则,对修改关闭,对扩展开放

我们通过上面的优化,把获取原材料的过程封装到VegetableMarketProvider的子类中,然后通过对象组合的方式实现了对菜市场的更换,这样的方式进一步解耦了我们的代码,每一个类都各司其职,保证了职责的单一

那我们再来简单总结一下抽象工厂这个方式吧。

  • 通过使用一个抽象类,把相关的接口进行了定义,然后继承这个抽象类的子类都具有相同的接口和属性。这样用到这些子类的类可以对这些类进行接口编程,而不是在针对具体的类
  • 对象的创建过程被封装在子类中,这样实现了代码的封装以及类依赖的解耦
  • 使用不同的子类,通过对象的组合,我们可以实现我们想要的创建不同对象的功能

文章到这里就结束了,如果大家有什么问题和疑问欢迎大家在文章下面留言,或者在这里提出来。也欢迎大家关注我的公众号关山不难越,获取更多关于设计模式讲解的内容。

下面是这一系列的其它的文章,也欢迎大家阅读,希望大家都能够掌握好这些设计模式的使用场景和解决的方法。如果这篇文章对你有所帮助,那就点个赞,分享一下吧~

文章封面图来源:unDraw

查看原文

赞 13 收藏 11 评论 0

dreamapplehappy 关注了用户 · 2020-11-03

Yumiku @yumiku

只想做一个简简单单的程序员,偶尔学点算法,尝试尽力但不一定能定期更新,主要是一些后端深入、算法基础讲解(经典算法和人工智能算法)。

目标是在2021年2月找到提前批实习
-`д´-

关注 13

dreamapplehappy 赞了文章 · 2020-11-03

为什么要用Go语言?

本文章创作于2020年4月,大约6000字,预计阅读时间15分钟,请坐和放宽。

logo.png

前言

Go 是一个开源的编程语言,它能让构造简单、可靠且高效的软件变得容易[1]。

Go 语言被设计成一门应用于搭载 Web 服务器,存储集群或类似用途的巨型中央服务器的系统编程语言。对于高性能分布式系统领域而言,Go语言无疑比大多数其它语言有着更高的开发效率。它提供了海量并行的支持,这对于游戏服务端的开发而言是再好不过了[1]。

其实早在2018年前,我就已经有在国内的程序员环境中断断续续地听到Go语言的消息,Go语言提供的方便的并发编程方式,十分适合我当时选择的毕业设计选题,但是受限于导师的语言选择、项目的进度追赶、考研的时间压榨,一直没有机会来好好地学习这门语言。

在进入研究生阶段后,尽管研究的方向和算法相关,但未来的职业方向还是选择了以后端为主,主要是因为想做更多和业务相关的工作。为了能在有限的时间里给予自己足够深的知识底蕴,选择了一些让自己去深入了解的方向,Go语言自然也在其中,今天终于有机会来开始研究这门语言。

为什么要用Go语言?

撰写此文的初衷,是本文的标题,也是我作为初学者一直以来的疑问:

“我为什么要用Go语言?”

为了回答这个问题,我翻阅了很多Go语言相关的文档、书籍和教程,我发现我很难在它们之中找到非常明显直接的答案,书上和教程只会说,“是的,Go语言好用”

对于部分人来说,这个问题的答案或许很“明显”,比如选择Go语言是因为Google设计的语言、Go开发赚的钱多、XX公司使用Go语言等等,如果想要了解这门语言更加本质的东西,仅仅这些答案我认为是还不够的。

部分Go的教徒可能会说,他们选择的理由是和语言本身相关的,比如:

  • Go编译快
  • Go执行快
  • Go并发编程方便
  • Go有垃圾回收(Garbage Collection, GC)

的确,Go是有这些特点,但这并非都是Go独有的

  • 运行时解释的脚本语言(比如Python)几乎不需要时间编译
  • C、C++甚至是汇编,基本上能够榨干一台机器的大部分性能
  • 大部分语言都有并发编程的支持库
  • 大部分语言都不需要程序员主动关注内存情况

一些Go的忠实粉丝把这种All in One的特性作为评价语言的标准,他们认为至少在这些方面,Go是可以完美的代替其他语言的。

那么,Go真的能优秀到完全替代另一个语言么?

其实未必,我始终认为银弹是不存在的[2],无论是在这次调查前,还是在这次调查后。

本文从Go语言被设计的初衷出发,深入互联网各种角落,调查Go所具有的那些特性是否足够优秀,同时和其他语言进行适当的比较,你可以选择性的阅读、接受或者反对我的内容,毕竟有交流才能传播知识。

我的最终目的是让更多的初学者看到Go没有轻易暴露出的缺点,同时也能看到Go真正优秀的地方

设计Go的初衷

Go语言的主要目标是将静态语言的安全性和高效性与动态语言的易开发性进行有机结合,达到完美平衡,从而使编程变得更加有乐趣,而不是在艰难抉择中痛苦前行[3]。

Google公司不可能无缘无故地设计一个新语言(一些特性相比于其他语言也没有新到哪里去),这一切肯定是有原因的。

设计Go语言是为了解决当时Google开发遇到的一些问题[4]:

  • C++编译慢、没有现代化(入门级友好的)的内存管理
  • 数以万计行的代码,难以维护
  • 部署的平台各式各样,交叉编译困难
  • ......

joke.png

找不到什么合适的语言,想着反正都是弄来自己用,Google选择造个轮子试试。

Go 语言起源 2007 年,并于 2009 年正式对外发布。它从 2009 年 9 月 21 日开始作为谷歌公司 20%兼职项目,即相关员工利用 20% 的空余时间来参与 Go 语言的研发工作。该项目的三位领导者均是著名的 IT 工程师:Robert Griesemer,参与开发 Java HotSpot 虚拟机;Rob Pike,Go 语言项目总负责人,贝尔实验室 Unix 团队成员,参与的项目包括 Plan 9,Inferno 操作系统和 Limbo 编程语言;Ken Thompson,贝尔实验室 Unix 团队成员,C 语言、Unix 和 Plan 9 的创始人之一,与 Rob Pike 共同开发了 UTF-8 字符集规范。自 2008 年 1 月起,Ken Thompson 就开始研发一款以 C 语言为目标结果的编译器来拓展 Go 语言的设计思想[3]。

go-designers.png

Go 语言设计者:Griesemer、Thompson 和 Pike [3]

当时Google的很多工程师是用的都是C/C++,所以语法的设计上接近于C,Go的设计师们想要解决其他语言使用中的缺点,但是仍保留他们的优点[5]:

  • 静态类型和运行时效率
  • 可读性和易用性
  • 高性能的网络和多进程
  • ...

emmm,这些听起来还是比较玄乎,毕竟设计归设计,实现归实现,我们回顾一下现在Go的几个主要特点,编译速度、执行速度、内存管理以及并发编程。

Go的编译为什么快

当然,设计Go语言也不是完全从零开始,最初Go的团队尝试设计实现一个Go语言的编译前端,由基于C的gcc编译器来编译成机器代码,这个面向gcc的前端编译器也就是目前的Go编译器之一的gccgo。

与其说Go的编译为什么快,不如先说说C++的编译为什么慢,C++也可以用gcc编译,编译速度的大部分差异很有可能来源于语言设计本身。

在讨论问题之前,其中需要先说明的一点是:这里比较的编译速度都是在静态编译下的

静态编译和动态编译的区别:

  • 静态编译:编译器在编译可执行文件时,要把使用到的链接库提取出来,链接打包进可执行文件中,编译结果只有一个可执行文件。
  • 动态编译:可执行文件需要附带独立的库文件,不打包库到可执行文件中,减少可执行文件体积,在执行的时候再调用库即可。

两种方式有各自的优点和缺点,前者不需要去管理不同版本库的兼容性问题,后者可以减少内存和存储的占用(因为可以让不同程序共享同一个库),两种方式孰优孰弱,要对应到具体的工程问题上,Go默认的编译方式是静态编译

回到我们要讨论的问题:C++的编译为什么慢?

C++编译慢的主要两个大头原因[6]

  • 头文件的include方式
  • 模板的编译

C++使用include方式引用头文件,会让需要编译的代码有乘数级的增加,例如当同一个头文件被同一个项目下的N个文件include时,编译器会将头文件引入到每一份代码中,所以同一个头文件会被编译N次(这在大多数时候都是不必要的);C++使用的模板是为了支持泛型编程,在编写对不同类型的泛型函数时,可以提供很大的便利,但是这对于编译器来说,会增加非常多不必要的编译负担。

当然C++对这两个问题有很多后续的优化方法,但是这对于很多开发者来说,他们不想在这上面有过多时间和精力开销。

大部分后来的编程语言在引入文件的方式上,使用了import module来代替include 头文件的方式,import解决了重复编译的问题,当然Go也是使用的import方式;在模板的编译问题上,由于Go在设计理念上遵循从简入手,所以没有将泛函编程纳入到设计框架中,所以天生的没有模版编译带来的时间开销(没有泛型支持也是很多人不满Go语言的理由)。

在Go 的1.5 版本中,Go团队使用Go语言来编写Go语言的编译器(也叫自举),相比于gccgo来说:

  • 提高了编译速度,但执行速度略有下降(性能细节优化还不如gcc)
  • 增加了可编译的平台类型(以往受限于gcc)

在此之外,Go语言语法中的关键字也是非常少的(Go1.11版本里只有25个)[7],这也可以减少编译器花费在语法解析上的时间开销。

keywords.png

所以在我看来,Go编译速度快,主要出于四个原因

  • 使用了import的引用管理方式;
  • 没有模板的编译负担;
  • 1.5版本后的自举编译器优化;
  • 更少的关键字。

所以为了加快编译速度、放弃C++而转入Go的同时,也要考虑一下是否要放弃泛型编程的优点。

注:泛型可能在Go 2版本获得支持。

Go的实际性能如何

Go的执行速度,可以参考一个语言性能测试数据网站 —— The Computer Language Benchmarks Game[8]。

这个网站在不同的算法上对每个语言进行测试,然后给出时间和内存上的开销数据比对。

比较的语言有C++、Java、Python。

首先是时间开销:

time-cost.png

注意时间开销的单位是s,并且Y轴为了方便进行不同跨度上的比较,所以选取的是对数轴(即非线性轴,为1-10-100-1000的比较跨度)。

然后是内存开销:

mem-cost.png

注意Y轴为了方便进行不同跨度上的比较,所以选取的是对数轴(即非线性轴,为1000-10000-100000-1000000的比较跨度)。

需要注意的是,语言本身的性能只决定了一个程序的最高理论性能,程序具体的性能还要取决于这个程序的实现方法,所以当各个语言的性能并没有太大的差异时,性能往往只取决于程序实现的方式。

通过两个图的数据可以分析:

  • Go虽然还无法达到C++那样的极致性能,但是在大部分情况下已经很接近了
  • Go和Java在算法的时间开销上难分伯仲,但在内存的开销上Java就要高得多了;
  • Go在上述的绝大部分情况下,至少时间和内存开销都比Python要优秀得多;

Go的并发编程

Go的并发之所以比较受欢迎,网络上的很多内容集中在几个方面:

  • 天生并发的设计
  • 轻量化的并发编程方式
  • 较高的并发性能
  • 轻量级线程Goroutines、并发通信Channels以及其他便捷的并发同步控制工具

由于Go在设计的时候就考虑到了并发的支持,或者说很多特性都是为了并发而设计,这和一些后期库支持并发和第三方库支持并发的语言不同。

所以Go的并发到底有多方便?在Go中使用并发,只需要在普通的函数执行前加上一个go关键字,就可以新建一个线程让函数在其中执行:

func main() {
    go loop() // 启动一个goroutine
    loop()
}

这样带来的好处不仅仅是让并发编程更方便了,在一些特定情况下,比如Go引用一些使用了并发的库时,这些库所使用的并发也是基于Go本身的并发设计,不会存在库使用另一套并发实现的情况,这样Go调度器在处理程序中的各种并发线程时,可以有更加统一化的管理方式。

不过Go的并发对于程序的实现要求还是比较高的,在使用一些通信Channel的场合,稍有疏忽就可能出现死锁的问题,比如:

fatal error: all goroutines are asleep - deadlock!

Go的并发量可以比大部分语言里普通的线程实现要高,这受益于轻量级的Goroutine,轻量化主要是它所占用的空间要小得多,例如64位环境下的JVM,它会默认固定为每个线程分配1MB的线程栈空间,而Goroutines大概只有4-8KB,之后再按需分配。足够轻量化的线程在相同的内存下也就可以有更高并发量(服务器CPU还没有饱和的情况下),同时也可以减少很多上下文切换的时间开销[9]。但是如果你的每个线程占用空间都非常大时(比如10MB,当然这是非常规需求的情况下),Go的轻量化优势就没有那么明显了。

Go在并发上的优点很明显,也是Go的功能目标,从语言设计上支持了并发,提供了统一便捷的工具,复杂的并发业务也需要在Go的一整套并发规范体系下进行编程,当然这肯定会牺牲部分实现自由度,但可以获得性能的提高和维护成本的下降。

PS:关于Go调度器的内容在这里并没有被提及,因为很难用简单的文字向读者说明该调度方式和其他调度方式的优劣,将在未来的某一篇中会细致地介绍Go调度器的内容。

Go的垃圾回收

垃圾回收(英语:Garbage Collection,缩写为GC),在计算机科学中是一种自动的存储器管理机制。当一个计算机上的动态存储器不再需要时,就应该予以释放,以让出存储器,这种存储器资源管理,称为垃圾回收。垃圾回收器可以让程序员减轻许多负担,也减少程序员犯错的机会[10]。

在使用Go或者其他支持GC的语言时,不用再像C++一样,手动地去释放不需要的变量占用的内容空间(free/delete)

的确,这很方便(对于懒人和容易忘记主动释放的人),但是也多了一些限制(暗箱操作的不透明性以及在GC处理上的性能开销)。GC也不是万能的,当遇到一些对性能要求较高的场景,还是需要记得进行一些主动释放或优化操作(比如说自定义内存池)。

PS:将在未来的某一篇中会细致地介绍Go垃圾回收的细节(如果你们也觉得有必要的话)。

什么时候可以选择Go?

Go有很多优点,编译快、性能好、天生并发以及垃圾回收,很多比较有特色的内容也还没有说到(比如gofmt)。

Go语言也有很多缺点,比如第三方库支持还不够多(相比于Python来说就少的太多了)、支持编译的平台还不够广、还有被称为噩梦的依赖版本管理(已经在改善了,但是还没有达到完全可靠的程度)。

所以到底Go适合做什么,不适合做什么?

分析了这么多后,这个问题其实很难回答,但我们可以选择先从不适合的领域把Go剔除掉,看看我们会剩下什么。

Go不适合做什么

  • 极致高性能优化的场景,你可能需要使用C/C++,甚至是汇编;
  • 简单流程的脚本工具、数值分析、深度学习,可能Python更适合(至少目前是);
  • 搭一个博客或网站,PHP何尝不是天下第一的语言呢;
  • 如果你想比较方便找到一份的后端工作,绝大部分公司的Java岗一直缺人(在实际生产过程中,目前Go仍没有比Java表现得好太多,至少没有好到让一个部门/公司将核心业务重新转向Go来进行重构);
  • ...

你可以找到类似上面那样的很多场景,你可能会发现Go并不能那么完美地替代掉谁。

Go适合做什么

最后,到了我们的终极问题,Go到底适合做什么?

读到这里你可能会觉得,好像是我把Go的特性吹了一遍,然后突然告诉你可能Go不适合你。

Go天生并发,面向并发,所以Go的定位一直很清楚,从最浅显的视角来看,至少Go作为一个有较高性能的并发后端来说,是具有非常大的诱惑力的。

尤其对于后端相关的程序员而言,在某些业务功能的初步实现上,简洁的语法、内置的并发、快速的编译,都可以让你更加高效快速地完成任务(前提是Go的内容足以完成你的任务),不用再去担忧编译优化和内存回收、不用担心过多的时间和内存开销、不用担心不同版本库之间的冲突(静态编译)以及不用担心交叉编译平台适配问题。

大部分情况下,编写一个服务,你只需要:实现、编译、部署、运行

高效快速,足够敏捷,这在企业的绝大部分项目的初期都是适用的,这也是大部分项目对开发初期的要求。当一个项目或者服务真的可以发展下去,需求的确触碰到Go的天花板时,再考虑使用更加好的语言或方法去优化也为时不晚。

简而言之,尽管Go的过于简洁带来了很多问题(有些人说的难听点叫过于简单),Go所具有的优点,可以让大部分人用编程语言这种工具,来解决对他们而言更加重要的问题。

Go语言不是银弹,但它的确能有效地解决这些问题。

参考文章

扩展阅读

在调查Go的过程中,发现了一些比较有意思、或者比较实用的文章,一并附在这里。

  • 我为什么选择使用 Go 语言?,该文写于2016年,在我的文章基本构思完成的时候,偶然看到了这篇文章,作者有很多早期Go版本的开发经验,里面有更多的细节都是出自于工程师的经验之谈,我发现其中的部分想法和我不谋而合,你可以把这篇文章当作本文的后续扩展阅读,不过要注意文章的时效,可能提及到的一些Go的缺点现在已经被改进了。
  • C/C++编译器的工作过程,主要是供不熟悉C系的朋友了解一下编译器的工作过程。
  • The Computer Language Benchmarks Game,一个对各个语言进行性能测试的网站,里面的算法具有一定的代表性,但是不能代表所有工程可能遇到的情况,仅供参考。
  • 为什么 Go 语言在某些方面的性能还不如 Java?,这是知乎上一个2017年开始有的问题,你可以看到很多人对于这个问题的分析,从多个角度来理解语言之间的性能差异。
  • go-wiki WhyGo,Go的Github仓库上维护的Wiki中,有一篇关于WhyGo的文章整理,不过大部分是英文,里面主要是很多关于“为什么我要选择Go”的软硬稿。
  • 为什么要使用Go语言,Go语言的优势在哪里,这个知乎的提问更早,是来自2013年的Yvonne YU用户,在Go的早期其实是具有很大的争议的,你可以看到大家在各个问题上的博弈。
  • 哪些公司在使用Go,Go的Github仓库上维护的Wiki中,有一篇关于全球都有哪些公司在使用Go,不过提供的信息大部分只有一个公司名,比如国内有阿里巴巴(而人家大部分都招Java),可以看看但参考性不大。
  • Go 语言的优点,缺点和令人厌恶的设计,这是Go语言中文网上一篇2018年的文章,如果你对语言本身的一些特性的设计感兴趣,你可以选择看看,作者从很多语法层面上介绍了Go的优点和缺点。
  • Ruby China - 瞎扯淡 真的没必要浪费心思在 Go 语言上,这是我无意中找到的一篇有名的帖子,这个问题始于2013年,在Ruby China上,其中也是大佬们(可能)从各个角度来辩论Go是否值得学习,可以当作武侠小说观看。
  • The way to Go - 3.8 Go性能说明,《The way to Go》这本书上为数不多关于Go性能问题的说明。
  • C++开发转向go开发是否是一个好的发展方向?,2014年知乎上关于C++和Go的一个讨论,其实我觉得“如果选择一个并不意味着就要放弃另一个”,程序员不是研究语言的,也不应该是只靠某一门语言吃饭。
  • 我为什么放弃Go语言 Liigo,嗯,2014年,仍旧是Go争议很大的时候,CSDN上一篇阅读数很高的文章,作者从自己的角度对Go进行批判(Go早期的确是有不少问题),你可以看到早期Go的很多问题,也可以斟酌这些问题对你是否重要以及到底在2020年的Go中有没有被解决。
  • Golang 本身是用什么语言写的?,一个关于编译的有趣的问题,可以适当了解。
  • 搞懂Go垃圾回收,一篇还算比较新的分析Go垃圾回收问题的文章。
  • 有趣的编程语言:Go 语言的启动时间是 C 语言的 300 多倍,C# 的关键字最多,这篇InfoQ文章其实算是一个典型的标题党,主要使用的是一个Github上关于各个语言HelloWorld程序启动时间的测试数据(https://github.com/bdrung/sta...,使用gccgo编译的Go程序的启动时间非常地长,的确是C的300多倍,但使用GC编译的Go程序启动时间只是C的2倍。
  • Go 语言的历史回顾,我一直在寻找一个整理Go的版本变动细节的文章,在Go的官方文档和各种书籍上寻找无果时,在InfoQ上找到了一篇还算跟踪地比较新的(Go 1.0 - Go 1.13)文章,对于初学者而言,知道语言的变化也是很重要的(比如方便的知道哪些问题解决了,哪些还没有被解决),可能之后会拓展性的写一篇关于这个的文章。
查看原文

赞 20 收藏 9 评论 3

dreamapplehappy 发布了文章 · 2020-11-02

设计模式大冒险第二关:装饰者模式,煎饼果子的主场

封面图

这是关于设计模式系列的第二篇文章,在这个系列中,我们尽量不使用那些让你一听起来就感觉头大的解释设计模式的术语,那样相当于给大家带去了新的理解难度。我们会使用生活中的场景以及一些通俗易懂的小例子来给大家展示每一个设计模式使用的场景以及要解决的问题。

这篇文章我们来讲解装饰者模式,那么什么是装饰者模式呢?对于名字来说你可能会感到比较陌生,但是你在生活中肯定经常使用这个模式去解决生活中的一些问题。只是你并不知道它原来是装饰者模式而已

生活中的装饰者模式

想象一下,夏天到了,你家住在比较低的楼层,一到晚上许多的蚊子就到你家里做客,它们对你的身体进行大快朵颐让你很烦恼。你这时才发现家里的窗户上没有装上窗纱,所以一到晚上如果不及时关闭窗户的话,那么就会有很多蚊子来拜访你。但是你想晚上感受一下微风徐来,又不想被蚊子拜访,那么你要做的就是给窗户装上窗纱。

对,给窗户装上窗纱就是使用了装饰者模式。我们没有对原来的窗户做任何的更改,它还是那个窗户,可以打开和关闭,可以透过它观看风景,可以感受微风徐来。增加了窗纱之后,我们的窗户有了新的功能,那就是可以阻止蚊子进入室内了。这样我们就拓展了窗户的功能,但是没有对原来的窗户做什么改变

生活中还有很多这样的例子,这里就不一一列举了,相信你看完这篇文章之后,会对这个设计模式有更深一步的理解。然后能够发现生活中更多这样的例子,进而加强你对这个设计模式的理解与掌握。

那么在开发中我们需要使用这个设计模式来解决什么问题呢?我们要解决的是这样的问题:在不改变已有对象的属性和方法的前提下,对已有对象的属性和功能进行拓展

你会好奇为什么要这样做呢?首先已有的对象可能是你不能够修改的,为什么不能够修改?可能因为这个对象是第三方库引入的,或者是代码中全局使用的,或者是一个你还不是很熟悉和了解的对象。这些情况下,你是不能够轻易在这些对象上添加新的功能的。但是你又不得不对这个对象增加一些新的功能来满足当下的开发需求。所以这时候,使用装饰者模式就可以很好地解决这个问题。我们赶紧来学习一下吧~

通过一个例子来实战装饰者模式

楼下卖煎饼果子的老板知道你会编写程序,所以想让你来帮忙写一个点餐的小程序,来方便他给买煎饼果子的客户点餐。报酬就是以后你来买煎饼果子给你打88折,你一听感觉还不错,所以就答应了下来。

当你准备开始的时候,老板告诉你说他的点餐系统已经有一部分代码了,并且希望你不要修改这些代码,因为他不确定这些代码在他的点餐系统中是否有用过。修改之后可能会导致一些问题,所以你只能在之前的基础上添加新的功能。老板给的代码如下:

// 煎饼果子
class Pancake {
  constructor() {
    this.name = "煎饼果子";
  }

  //获取煎饼果子的名字
  getName() {
    return this.name;
  }

  // 获取价格
  getPrice() {
    return 5;
  }
}

老板要求如下:

  • 不能够修改之前的代码
  • 煎饼果子可以随意搭配鸡蛋,香肠,和培根,并且每一种的数量没有限制
  • 点餐完成之后能够展示当前煎饼果子包含搭配的配料,以及价格

你现在不可以修改已有的代码,但是却要增加新的功能,这对你来说还是有一点点难度的。但是好巧的是你刚刚学习完装饰者模式,使用这个设计模式就可以很好地解决这个问题。而且是在不修改原来的代码的情况下。你马上回到家中开始为你的88折优惠努力开发起来。

对原有对象的基本装饰

在开始对原来的对象进行具体的装饰之前,我们需要写一个基本的装饰类,如下所示:

// 装饰器需要跟被装饰的对象具有同样的接口
class PancakeDecorator {
  // 需要传入一个煎饼果子的实例
  constructor(pancake) {
    this.pancake = pancake;
  }
  // 获取煎饼果子的名字
  getName() {
    return `${this.pancake.getName()}`;
  }
  // 获取煎饼果子的价格
  getPrice() {
    return this.pancake.getPrice();
  }
}

我们看一下上面的代码,你会发现PancakeDecorator除了构造器需要传递一个Pancake的实例之外,其他的方法跟Pancake是保持一致的。

这个基本装饰类的目的是为了让我们后面开发的具体的装饰类跟被装饰的对象具有相同的接口,为了后面的组合和委托功能做好铺垫

开发具体的装饰类

我们知道老板的配料有鸡蛋培根,还有香肠。所以我们接下来需要开发三个具体的装饰类,代码如下所示:

// 煎饼果子加鸡蛋
class PancakeDecoratorWithEgg extends PancakeDecorator {
  // 获取煎饼果子加鸡蛋的名字
  getName() {
    return `${this.pancake.getName()}➕鸡蛋`;
  }

  getPrice() {
    return this.pancake.getPrice() + 2;
  }
}

// 加香肠
class PancakeDecoratorWithSausage extends PancakeDecorator {
  // 加香肠
  getName() {
    return `${this.pancake.getName()}➕香肠`;
  }

  getPrice() {
    return this.pancake.getPrice() + 1.5;
  }
}

// 加培根
class PancakeDecoratorWithBacon extends PancakeDecorator {
  // 加培根
  getName() {
    return `${this.pancake.getName()}➕培根`;
  }

  getPrice() {
    return this.pancake.getPrice() + 3;
  }
}

从上面的代码我们可以看到,每一个具体的装饰类都只对应一种配料,然后每一个具体的装饰类因为继承自PancakeDecorator,所以跟被装饰类保持相同的接口。在方法getName中,我们首先先获取当前传入进来的pancake的名字,然后在后面添加上当前装饰器对应的配料的名字。在getPrice方法中,我们使用同样的方法,获取添加这个装饰器指定的配料后的价格。

写完了上面的具体的装饰器之后,我们的工作就基本完成啦。我们来写一些测试代码,来验证一下我们的功能是否满足需求。测试的代码如下:

let pancake = new Pancake();
// 加鸡蛋
pancake = new PancakeDecoratorWithEgg(pancake);
console.log(pancake.getName(), pancake.getPrice());
// 加香肠
pancake = new PancakeDecoratorWithSausage(pancake);
console.log(pancake.getName(), pancake.getPrice());
// 加培根
pancake = new PancakeDecoratorWithBacon(pancake);
console.log(pancake.getName(), pancake.getPrice());

输出的结果如下:

煎饼果子➕鸡蛋 7
煎饼果子➕鸡蛋➕香肠 8.5
煎饼果子➕鸡蛋➕香肠➕培根 11.5

结果跟我们的预期是一致的,所以我们上面的代码已经很好地完成了老板的需求。可以马上交给老板去使用了。

装饰者模式的组合和委托

也许通过上面的代码你还没有能够完全理解这样做的目的,没关系,我来给大家再展示一个关于这个模式的示例图,相信看过这个实例图你肯定会理解得很深刻的。

装饰者模式图解

  • 第一步:调用PancakeDecoratorWithSausage实例的getPrice方法。
  • 第二步:因为PancakeDecoratorWithSausage实例的getPrice方法需要访问PancakeDecoratorWithEgg的实例,所以进入第三步。
  • 第三步:因为PancakeDecoratorWithEgg实例的getPrice方法需要访问PancakeDecorator的实例,所以进入第四步。
  • 第四步:因为PancakeDecorator实例的getPrice方法需要访问Pancake的实例,进入第五步。
  • 第五步:通过Pancake的实例返回不加料的煎饼果子的价格是5元。
  • 第六步:PancakeDecorator实例获取原始的煎饼果子的价格,返回这个价格。
  • 第七步:PancakeDecoratorWithEgg实例获取到PancakeDecorator返回的价格5元,再加上配料鸡蛋的价格2元,所以返回7元。
  • 第八步:PancakeDecoratorWithSausage实例获取到PancakeDecoratorWithEgg实例返回的价格7元,再加上配料香肠的价格1.5元,返回价格8.5元。

从上面的这幅图我们可以清楚地看到这个过程的变化,我们通过组合和委托实现了添加不同配料的价格计算。所谓的委托就是指我们没有直接计算出当前的价格,而是需要委托方法中另外一个实例的方法去获取相应的价格,直到访问到最原始不加料的煎饼果子的价格,再逐次返回委托得到的结果。最终算出加料后的价格。有没有感觉这个过程跟DOM事件的捕获冒泡很相似。

所谓的组合,就是指,我们不需要知道当前的煎饼果子的状态,只需要把这个煎饼果子的实例当做我们具体装饰类的构造函数的参数,然后生成一个新的煎饼果子的实例,这样就可以给传入进来的煎饼果子添加相应的配料

怎么样,是不是感觉装饰者模式还挺简单的,而且也很有用。好了,我们需要把这些代码交给煎饼果子的老板了,让他去试用一下,看看怎么样。大家可以在这里体验一下这个不完善的煎饼果子点餐系统,下面的动图是一个简单的操作演示,大家可以提前感受一下。

操作演示

对装饰者模式的一些思考

每当学习完一个新的知识之后,我们要学着把这个知识点纳入我们已有的知识系统中;比如学习完了装饰者模式,你可能会想到我应该在什么情况下使用这种设计模式?我现在已经掌握的知识中有没有跟这个相关联的?这种设计模式有没有什么弊端?等等,需要你自己深入的思考沉淀一下。

装饰者模式的一些延伸

经常使用React来开发应用的小伙伴这个时候是不是想到了React的高阶组件?我们看看React的文档中是如何描述高阶组件的:

A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, per se. They are a pattern that emerges from React’s compositional nature.

React通过高阶组件,可以使用组合的方式复用组件的逻辑,这是一种高级的技巧😁。你现在已经掌握这种高级的技巧了。

如果你对JavaScript的未来发展比较关注的话,那么你肯定知道在以后的JavaScript版本中,可能会在语言的原生层面增加对装饰器的支持。更多详细的资料大家可以在tc39/proposal-decorators这里获取。

比如如果在语言的原生层面支持装饰器的话,我们可以写出下面的代码:

@annotation
class MyClass { }

function annotation(target) {
   target.annotated = true;
}

上面的代码来自babel-plugin-proposal-decorators的示例。

在上面的代码中,@annotation是类MyClass的装饰器,这个装饰器给我们的MyClass类添加了一个属性annotated,并且把这个属性的值设置为true。这个过程不需要我们对原来的类MyClass做任何修改,就实现了给这个类添加一个属性的功能。是不是很棒~

我们也可以验证一下:

console.log(MyClass.annotated);  # true

装饰者模式适用的场景以及可能存在的问题

装饰者模式利用组合和委托的特性,能够让我们在不改变原来已有对象的功能和属性的情况下增加新的功能和属性,让我们能够保持代码的低耦合和可扩展性。是一种很不错的设计模式。

但是使用装饰者模式也有潜在的问题,因为随着装饰者的增多,代码的复杂性也随之增加了,所以要确保在合适的场景下使用装饰者模式。

文章到这里就结束了,如果你有什么意见和建议欢迎给我留言。你还可以关注我的公众号关山不难越,获取更多关于设计模式的文章讲解以及好玩有趣的前端知识。

相关阅读推荐:

查看原文

赞 9 收藏 4 评论 2

dreamapplehappy 发布了文章 · 2020-10-26

快使用Scriptable自己开发一个iPhone小组件吧

最近苹果的 iOS 系统升级到了 iOS 14,这次的更新我比较关注的就是升级的小组件功能,这次更新我们可以将小组件放置在主屏幕中的任何位置,可以让我们更加便捷的查看一些信息,从而省去了还需要打开APP去查看消息的步骤,感觉很方便。

看到这里一些同学可能会说,功能是挺不错的,如果我自己也能开发一个小组件展示自己想看的内容就好了。是呀,哪一个小男孩不想拥有一个专属于自己的 iOS 小组件

别慌,最近发现了一个APP可以让我们通过使用JavaScript来创建我们自己想要的小组件。这个APP的名字就是Scriptable,还是验证了Jeff Atwood那句话,任何可以使用JavaScript来编写的应用,最终会由JavaScript编写。作为一个前端有没有感觉很开心,又有一个地方可以让你来大展身手了,那就趁热赶紧来了解一下吧。

Scriptable的简单介绍

Scriptable

工欲善其事,必先利其器,我们先来了解一下Scriptable有哪些作用吧,从上面官网上的介绍我们可以知道,这个APP可以让我们使用JavaScript来自动化iOS。这句话是什么意思呢?就是我们可以提前编写好一些代码去执行一些特定的任务,比如:获取GitHub上面的Trending项目的名字和介绍、了解Hacker News的最新资讯、获取自己的最近日程、以及自己的TODO列表等等。当然这都只是一些最基本的使用场景,你肯定有自己的想法,看完这篇文章之后就去自己去实现一个独一无二的小应用吧。

Scriptable

上面列举的是一些Scriptable的特性,这些特性包括:

  • 支持ES6语法
  • 可以使用JavaScript调用一些原生的API
  • Siri 快捷方式
  • 完善的文档支持
  • 共享表格扩展
  • 文件系统继承
  • 编辑器的自定义
  • 代码样例
  • 以及通过x-callback-url和其它APP交互

是不是感觉还是挺不错的,这些特性已经可以让我们去做很多可以自动化的事情了。

开始前的准备工作

  • 一台升级到 iOS 14iPhone 手机
  • 安装 Scriptable 应用程序

下载完成之后打开应用,我们可以看到一些已经写好的例子:

image

点击小卡片会直接运行相应的程序,点击小卡片右上角的更多按钮进入相应程序的代码编辑模式。

image

底部有一个悬浮的操作栏,左边第一个按钮是一个设置按钮,你可以为当前的小程序设置图标,颜色,等等:

image

左边第二个按钮是一个文档提示按钮,点击可以搜索想要使用的相关的API:

image

最右边是一个运行按钮,点击会直接在手机上运行你编写的应用程序。这个大家应该一看就知道了:

image

我以为通过在手机上的编辑器进行代码的编写会比较费劲和吃力,但是试了一下发现还好。因为手机上的编辑器也有比较完善的语法提示功能,编写代码的体验虽然不如在电脑上那般舒服,但也是在可以接受的范围之内。

image

上面是一些关于 Scriptable APP的简单的介绍,你可以自己在手机上好好体验一下。我觉得整个APP很简约,但是功能还是很强大的。

第一个 Hello World 小组件

我们学习编程语言的第一步就是输出Hello World,所以我们使用 Scriptable 的第一个小应用就是在主屏幕上展示Hello Wolrd

我个人觉得在你开始真正的开发自己想要的小组件之前,开发一个Hello World的小组件还是很有必要的,因为这个过程相对容易一点,可以增加我们的自信心。我们可以通过这个小程序来建立起我们开发小组件的手感,并且我们是可以直接在手机的屏幕上看到这个结果的,是不是还蛮有成就感的。

image

在编码的过程中有几个选择,你可以选择直接在手机的编辑器上进行编码,也可以通过 Mac 的 iCloud 云盘 的同步功能,在 Mac 电脑上用自己熟悉的编辑器开发。如果你有蓝牙的键盘,可以直接使用蓝牙键盘连接到手机上使用自己的键盘进行编码。根据自己的条件选择一个自己舒服的方式进行编码。

接下来就是Hello World小组件的代码了:

// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: cyan; icon-glyph: greater-than-equal;

// 以下代码仅供学习交流使用

// 判断是否是运行在桌面的组件中
if (config.runsInWidget) {
  // 创建小部件
  const widget = new ListWidget();
  // 添加文本
  const text = widget.addText("Hello, World!");
  text.textColor = new Color("#000000");
  text.font = Font.boldSystemFont(36);
  text.centerAlignText();
  // 添加渐变色背景
  const gradient = new LinearGradient();
  gradient.locations = [0, 1];
  gradient.colors = [new Color("#F5DB1A"), new Color("#F3B626")];
  widget.backgroundGradient = gradient;
  // 设置部件
  Script.setWidget(widget);
}

上面的代码还是比较简单的,相信大家看一下就明白了。我再简单介绍一下,最开头的注释是 Scriptable 自己生成的,用来设置小卡片的图标和图标颜色;接下来的一个if判断表明我们希望接下来的代码是在小组件的条件下运行的,用来生成我们的小组件。

然后接下来的代码就是创建小组件,添加文本,设置本文的颜色、字体以及对齐方式。然后添加了一个渐变的背景,最后把上面生成的小组件通过Script.setWidget()进行设置。这样我们的Hello World小组件就算完成啦。

“今天吃点啥”小组件

也许5分钟过后你就开始不满足一个简单的 Hello World 小组件了,你知道你的征途是星辰大海,所以你要做出一些有实际应用价值的小组件。但此时的你已经工作到晚上十点多了,十分想给自己点一个夜宵来犒劳一下自己。但是你特别纠结吃啥?

看了看楼下的炒粉干和山东杂粮煎饼以及烤冷面,你十分纠结要吃啥。所以为了节省时间我决定开发一个帮你选择吃什么的小组件。就叫它:“今天吃点啥”吧。

image

看看这个组件的图标是不是就很有食欲?当你不确定要吃啥的时候就点击这个小组件,然后我们编写的程序就会运行,它会在面板上列出你这次要吃的选项,你点击选择,马上就知道自己要吃啥了,是不是解决了你迟迟下不定决心要吃啥的纠结状态。

image

image

为了明确告诉你这次的选择是什么,我特意在选择之后给你发送一个选择结果的通知,是不是很人性化😂,你肯定还发现了为什么食物的名字与图片不符合。是的,我是故意这样做的,为了营造一种你即将吃大餐的假象😁。

下面是一张动图,可以提前感受一下这个过程:

image

因为我之前有帮助过同事使用Swift开发原生 iOS 的一些经验,所以这里面跟原生相关的一些API我看着还算熟悉的,也好上手。就算没有相关的开发经验关系也不大,毕竟文档对于相关API的解释都还算清楚的,相信你看一看就可以很快上手的。

因为这个小组件的代码量稍微多一点,就不在这里展示了;大家如果有兴趣的话可以在scriptable-scripts看到这个小组件的源码部分,写的时候比较仓促,还有很多可以完善的地方。如果你有什么好的意见也可以提出来,我们一起学习进步。

对小组件的一些思考

更新了 iOS 14 之后,发现手机上的很多APP都新增了相关的小组件,这让用户可以快速方便的浏览一些关键的信息,也可以快速直达具体的服务。对用户来说还是很有帮助的。

对于开发者来说,这里面也存在一些新的机遇。就算不会原生的 iOS 开发,我们也可以借助像Scriptable这样的小组件平台,来创造出一些有趣,有价值,有意义的小组件。

有没有发现小组件是不是跟小程序在某些方面很相似?感觉以后应该会出现系统级别的“小程序”平台,如果AndroidiOS再搞一个统一的开发平台,前端开发者又可以扬帆远航了,想想是不是有点小激动呢。。。

学习与交流

如果你对使用 Scriptable 开发小组件很有兴趣,也欢迎大家进Scriptable小组件交流群进行交流讨论。

文章到这里就结束了,如果你有什么意见和建议欢迎给我留言。你还可以关注我的公众号关山不难越,可以及时获取最新好玩有趣文章的更新。

注:“今天吃点啥”小组件的图标使用的是https://undraw.co/网站上的,相关食物的图片来自https://unsplash.com/,每张图片来自哪个创作者在代码的注释中有说明。

查看原文

赞 10 收藏 5 评论 0

dreamapplehappy 发布了文章 · 2020-10-12

设计模式大冒险第一关:观察者模式

封面图

最近把之前学习过的这些设计模式又再次温习了一下,觉得还是有很多收获的。确实有了温故知新的感觉,所以准备在每个设计模式复习完之后都能够写一篇关于这个设计模式的文章,这样会让自己能够加深对这个设计模式的理解;也能够跟大家一起来探讨一下。

今天我们来一起学习一下观察者模式,刚开始我们不需要知道观察者模式的定义是什么,这些我们到后面再去了解。我想先带着大家从生活中的一个小事例开始。从生活中熟悉的事情入手,会让我们更快速的理解这个模式的用途

生活中的小例子

相信大家都关注过一些公众号,那么对于一个公众号来说,如果有新的文章发布的话;那么所有关注这个公众号的用户都会收到更新的通知,如果一个用户没有关注或者关注后又取消了关注,那么这个用户就不会收到该公众号更新的通知。相信这个场景大家都很熟悉吧。那么如果我们把这个过程抽象出来,用代码来实现的话,你会怎么处理呢?不妨现在停下来思考一下。

通过上面的描述,我们知道这是一个一对多的关系。也就是一个公众号对应着许多关注这个公众号的用户。

关注公众号

那么对于这个公众号来说,它的内部需要有一个列表记录着关注这个公众号的用户,一旦公众号有了新的内容。那么对于公众号来说,它会遍历这个列表。然后给列表中的每一个用户发送一个内容跟新的通知。我们可以通过代码来表示这个过程:

// 用户
const user = {
    update() {
        console.log('公众号更新了新的内容');
    },
};

// 公众号
const officialAccount = {
    // 关注当前公众号的用户列表
    followList: [user],
    // 公众号更新时候调用的通知函数
    notify() {
        const len = this.followList.length;
        if (len > 0) {
            // 通知已关注该公众号的每个用户,有内容更新
            for (let user of this.followList) {
                user.update();
            }
        }
    },
};

// 公众号有新内容更新
officialAccount.notify();

运行的结果如下:

公众号更新了新的内容

上面的代码能够简单的表示,当公众号的内容发生了更新的时候,去通知关注该公众号的用户的过程。但是这个实现是很简陋的,还缺少一些内容。我们接下来把这些缺少的过程补充完整。对于公众号来说,还需要可以添加新的关注的用户,移除不再关注的用户,获取关注公众号的用户总数等。我们来实现一下上面的过程:

// 公众号
const officialAccount = {
    // 关注当前公众号的用户列表
    followList: [],
    // 公众号更新时候调用的通知函数
    notify() {
        const len = this.followList.length;
        if (len > 0) {
            // 通知已关注该公众号的每个用户,有内容更新
            for (let user of this.followList) {
                user.update();
            }
        }
    },
    // 添加新的关注的用户
    add(user) {
        this.followList.push(user);
    },
    // 移除不再关注的用户
    remove(user) {
        const idx = this.followList.indexOf(user);
        if (idx !== -1) {
            this.followList.splice(idx, 1);
        }
    },
    // 计算关注公众号的总的用户数
    count() {
        return this.followList.length;
    },
};

// 新建用户的类
class User {
    constructor(name) {
        this.name = name;
    }
    // 接收公众号内容更新的通知
    update() {
        console.log(`${this.name}接收到了公众号的内容更新`);
    }
}

// 创建两个新的用户
const zhangSan = new User('张三');
const liSi = new User('李四');

// 公众号添加关注的用户
officialAccount.add(zhangSan);
officialAccount.add(liSi);

// 公众号有新内容更新
officialAccount.notify();
console.log(`当前关注公众号的用户数量是:${officialAccount.count()}`);

// 张三不再关注公众号
officialAccount.remove(zhangSan);

// 公众号有新内容更新
officialAccount.notify();
console.log(`当前关注公众号的用户数量是:${officialAccount.count()}`);

输出的结果如下:

张三接收到了公众号的内容更新
李四接收到了公众号的内容更新
当前关注公众号的用户数量是:2
李四接收到了公众号的内容更新
当前关注公众号的用户数量是:1

上面的代码完善了关注和取消关注的过程,并且可以获取当前公众号的关注人数。我们还实现了一个用户类,能够让我们快速创建需要添加到公众号关注列表的用户。当然你也可以把公众号的实现通过一个类来完成,这里就不再展示实现的过程了。

通过上面这个简单的例子,你是不是有所感悟,有了一些新的收获?我们上面实现的其实就是一个简单的观察者模式。接下来我们来聊一聊观察者模式的定义,以及一些在实际开发中的用途。

观察者模式的定义

所谓的观察者模式指的是一种一对多的关系,我们把其中的叫做Subject(类比上文中的公众号),把其中的叫做Observer(类比上文中关注公众号的用户),也就是观察者。因为多个Observer的变动依赖Subject的状态更新,所以Subject在内部维护了一个Observer的列表,一旦Subject的状态有更新,就会遍历这个列表,通知列表中每一个Observer进行相应的更新。因为有了这个列表,Subject就可以对这个列表进行增删改查的操作。也就实现了ObserverSubject依赖的更新和解绑

我们来看一下观察者模式的UML图:

观察者模式UML模式

从上图我们这可以看到,对于Subject来说,它自身需要维护一个observerCollection,这个列表里面就是Observer的实例。然后在Subject内部实现了增加观察者,移除观察者,和通知观察者的方法。其中通知观察者的方式就是遍历observerCollection列表,依次调用列表中每一个observerupdate方法。

到这里为止,你现在已经对这个设计模式有了一些了解。那我们学习这个设计模式有什么作用呢?首先如果我们在开发中遇到这种类似上面的一对多的关系,并且的状态更新依赖的状态;那么我们就可以使用这种设计模式去解决这种问题。而且我们也可以使用这种模式解耦我们的代码,让我们的代码更好拓展与维护

当然一些同学会觉得自己在平时的开发中好像没怎么使用过这种设计模式,那是因为我们平时在开发中一般都会使用一些框架,比如Vue或者React等,这个设计模式已经被这些框架在内部实现好了。我们可以直接使用,所以我们对这个设计模式的感知会少一些

实战:实现一个简单的TODO小应用

我们可以使用观察者模式实现一个小应用,这个应用很简单,就是能够让用户添加自己的待办,并且需要显示已添加的待办事项的数量

了解了需求之后,我们需要确定那些是,哪些是。当然我们知道整个TODO的状态就是我们所说的,那么对于待办列表的展示以及待办列表的计数就是我们所说的。理清了思路之后,实现这个小应用就变得很简单了。

可以点击👉这里提前体验一下这个简单的小应用

TODO小应用

首先我们需要先实现观察者模式中的SubjectObserver类,代码如下所示。

Subject类:

// Subject
class Subject {
    constructor() {
        this.observerCollection = [];
    }
    // 添加观察者
    registerObserver(observer) {
        this.observerCollection.push(observer);
    }
    // 移除观察者
    unregisterObserver(observer) {
        const observerIndex = this.observerCollection.indexOf(observer);
        this.observerCollection.splice(observerIndex, 1);
    }
    // 通知观察者
    notifyObservers(subject) {
        const collection = this.observerCollection;
        const len = collection.length;
        if (len > 0) {
            for (let observer of collection) {
                observer.update(subject);
            }
        }
    }
}

Observer类:

// 观察者
class Observer {
    update() {}
}

那么接下来的代码就是关于上面待办的具体实现了,代码中也添加了相应的注释,我们来看一下。

待办应用的逻辑部分:

// 表单的状态
class Todo extends Subject {
    constructor() {
        super();
        this.items = [];
    }
    // 添加todo
    addItem(item) {
        this.items.push(item);
        super.notifyObservers(this);
    }
}

// 列表渲染
class ListRender extends Observer {
    constructor(el) {
        super();
        this.el = document.getElementById(el);
    }
    // 更新列表
    update(todo) {
        super.update();
        const items = todo.items;
        this.el.innerHTML = items.map(text => `<li>${text}</li>`).join('');
    }
}

// 列表计数观察者
class CountObserver extends Observer {
    constructor(el) {
        super();
        this.el = document.getElementById(el);
    }
    // 更新计数
    update(todo) {
        this.el.innerText = `${todo.items.length}`;
    }
}

// 列表观察者
const listObserver = new ListRender('item-list');
// 计数观察者
const countObserver = new CountObserver('item-count');

const todo = new Todo();
// 添加列表观察者
todo.registerObserver(listObserver);
// 添加计数观察者
todo.registerObserver(countObserver);

// 获取todo按钮
const addBtn = document.getElementById('add-btn');
// 获取输入框的内容
const inputEle = document.getElementById('new-item');
addBtn.onclick = () => {
    const item = inputEle.value;
    // 判断添加的内容是否为空
    if (item) {
        todo.addItem(item);
        inputEle.value = '';
    }
};

从上面的代码我们可以清楚地知道这个应用的每一个部分,被观察的Subject就是我们的todo对象,它的状态就是待办列表。它维护的观察者列表分别是展示待办列表的listObserver和展示待办数量的countObserver。一旦todo的列表新增加了一项待办,那么就会通知这两个观察者去做相应的内容更新。这样代码的逻辑就很直观明了。如果以后在状态变更的时候还要添加新的功能,我们只需要再次添加一个相应的observer就可以了,维护起来也很方便。

当然上面的代码只实现了很基础的功能,还没有包含待办的完成和删除,以及对于未完成和已完成的待办的分类展示。而且列表的渲染每次都是重新渲染的,没有复用的逻辑。因为我们本章的内容是跟大家一起来探讨一下观察者模式,所以上面的代码比较简陋,也只是为了说明观察者模式的用法。相信优秀的你能够在这个基础上,把这些功能都完善好,快去试试吧。

其实我们学习这些设计模式,都是为了让代码的逻辑更加清晰明了,能够复用一些代码的逻辑,减少重复的工作,提升开发的效率。让整个应用更加容易维护和拓展。当然不能为了使用而使用,在使用之前,需要对当前的问题做一个全面的了解。到底需不需要使用某个设计模式是一个需要考虑清楚的问题。

好啦,关于观察者模式到这里就结束啦,大家如果有什么意见和建议可以在文章下面下面留言,我们一起探讨一下。也可以在这里提出来,我们更好地进行讨论。也欢迎大家关注我的公众号关山不难越,随时随地获取文章的更新。

参考链接:

文章封面图来源:unDraw

查看原文

赞 9 收藏 5 评论 0

dreamapplehappy 发布了文章 · 2020-09-08

糟糕,在错误的分支开发了新功能,该怎么处理呢?

image

最近在开发项目的一个小需求的时候,发生了一件尴尬的事情。那就是当我把新功能开发完成的时候,忽然发现自己开发使用的分支是错误的分支。不过我记得之前学习git的时候有一个git stash的命令可以把当前没有提交的内容存档起来,然后可以在切换分支之后把当前的存档应用到目标分支。不过因为平时不怎么使用这个命令,所以有点生疏了,需要再次去看看文档。

花了十几分钟,把git stash相关的命令又再次温习了一下,接着就顺利地把这个问题给解决掉。因为平时的开发也都是遵循相关的git流程,一般不会出现什么错误,而且平时使用的git命令也都是一些常用的。这次出现这个问题有一部分原因是因为这个项目不是一个长期维护的项目,当开发新功能的时候,一打开项目,就以为还在自己的开发分支。也没及时检查一下开发的分支是否正确。更深层次的原因还是因为git掌握得不够好。也正好借这个机会,把相关的命令再次好好复习一下,也挺好的

其实当你在错误的分支开发了新功能之后,这里会有三种情况:

  • 新功能还没有在本地进行commit(提交),也就是我这次遇到的情况
  • 新功能已经在本地提交了,但是还没有push到远程仓库
  • 新功能已经在本地提交了,且push到了远程仓库

虽然我遇到的是第一种情况,那么当我解决这个问题之后,我很自然的就会想:如果遇到了另外两种情况我该怎么处理呢?这篇文章就跟大家一起探讨一下针对上述三种情况下,如果你在错误的分支开发了新功能,我们应该怎么做。

新功能还没有在本地进行commit(提交)

在这种情况下我们可以在当前分支下使用:

git stash

这个命令表示把我们当前修改的内容暂存起来,然后我们的工作区就恢复到在没有开发新功能之前的样子。

这个时候我们需要切换到正确的工作分支,然后运行命令:

git stash apply

这个命令表示把我们之前暂存的内容,应用到当前分支。这样我们就相当于把修改的内容从一个分支移动到了另一个分支,是不是很简单呢。

上面那两个命令也是我解决这个问题中使用的命令。我觉得不能满足于只解决这个问题,我需要详细的了解一下有关git stash的命令,接下来的内容是关于git stash的一些深入的内容,我们不仅要知其然,还要知其所以然。

首先我们需要知道使用git stash命令会把我们工作区和暂存区的修改保存下来,然后将这些修改的内容从当前的文件中移出并保存在存档库里面。所以我们就回到了之前没有修改过内容的干净的工作区。

git stash在没有添加任何参数的时候相当于git stash push命令,我们使用git stash创建一个当前修改的快照的时候,命令运行完会给出如下的信息:

Saved working directory and index state WIP on <branchname>: <hash> <commit message>

其中branchname是你当前所在分支的名字,hash是当前分支最近一次提交的hash值,commit message就是你最近的一次提交的时候添加的提交信息。

对于当前只想存储一个快照的情况下使用git stash是比较方便直观的,如果你在当前分支想存储多个快照,那么最好给每一个快照添加一些解释信息,以便使用的时候能够知道每一个快照都是干嘛的。

我们可以使用git stash push -m message来给每一个快照添加详细的说明信息,比如:

git stash push -m “add feature 1”

在这个命令行运行完成之后,在终端上会显示如下的信息:

Saved working directory and index state On <branchname>: add feature 1

根据终端显示的信息,我们可以知道当前这个快照是在那个分支产生的,并且有了add feature 1这个详细的描述,等到以后使用的时候会更加的清楚一点。

当我们有了很多快照的时候,我们可能想看一下当前的快照列表。这个时候我们可以使用git stash list来看一下当前的快照列表。在终端运行git stash list后,如果你在之前添加了一些快照的话,会显示如下的一些信息:

stash@{0}: On <branchname>: add feature 1
stash@{1}: On <branchname>: add feature 0
stash@{2}: On <branchname>: <message>
stash@{3}: WIP on <branchname>: 47e52ae <commit message>
stash@{4}: On <branchname>: <message>

从上面的信息中我们可以知道最新的快照是排在最上面的,存储快照的是一个栈,所以最新添加的快照是放在最上面的。

如果我们想查看最近一次快照跟生成快照当时已提交的文件之间的变化情况的话,可以使用命令git stash show。这个命令默认展示的是文件的差别统计。如果想展示具体改动的内容的话,可以使用git stash show -p

因为我们有不止一个快照,所以我们还想要看之前的快照跟产生这个快照当时已提交的版本之间的差异的话,我们可以在上面的命令后面添加快照的索引,比如如果你想看上面add feature 0这个快照的文件变动的话,可以使用下面的命令:

git stash show stash@{1} # 简略的信息
git stash show -p stash@{1} # 详细的内容更改

接下来就到了应用(恢复)快照的时候了,如果这时候你想把某个快照的内容应用于当前的分支的话,只需要运行命令:

git stash apply # 将最新的快照内容应用于当前分支
git stash apply stash@{n} # n表示具体的快照索引

这样就可以把之前保留的快照内容应用到当前的版本中了,在应用快照的过程中可能会产生冲突,这时候需要手动把冲突的内容处理一下,然后再次提交就可以了。

git stash apply可以添加--index参数,这个参数的作用是在应用快照的时候,会把之前已经添加到暂存区(索引区)的更改依旧保存在暂存区,如果不添加这个参数的话,所有的变更都会变成在工作区的变更(也就是没有保存在索引区的状态)。

我们可以测试一下,对一个文件进行更改,然后把更改添加(使用git add)到暂存区,然后再次添加一个更改,这次不添加到暂存区。我们运行git status命令会看到如下的内容:

On branch dev
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   20200830/index.html

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   20200830/README.md

当我们运行git stash命令,然后运行git stash apply命令之后,会看到如下信息:

On branch dev
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   20200830/README.md
        modified:   20200830/index.html

no changes added to commit (use "git add" and/or "git commit -a")

所以当不使用--index参数的时候,不会保存之前在暂存区的状态。

关于git stash还有一些其它的命令,比如:

  • git stash drop:丢弃一个快照
  • git stash pop:应用最新的快照到当前分支,如果应用成功的话就把这个快照从存储快照的栈中移除
  • git stash clear:清除所有的快照

关于git stash一些常用的命令和操作上面已经讲解的差不多啦,如果大家想继续了解更多的话,可以参考git-stash

上面的内容主要是在我们新开发的功能还没有提交的情况下所做的一些处理,当我们开发的新功能已经在本地提交了的情况下,我们该如何处理呢?接下来我们就来探讨一下这个问题。

新功能已经在本地提交了,但是还没有push到远程仓库

如果新开发的功能已经在本地提交了,但是我们开发的这个分支是一个错误的分支。这个时候根据情况的不同,可以有两种处理的方式。

新的功能需要添加在一个新的分支

首先我们需要知道在我们添加新功能之前,当前分支处于哪一个提交。可以运行命令:

git log --oneline

查看当前分支的提交,可以看到有以下内容的输出:

085095f (HEAD -> master) update 5
47e52ae update 3
14fefac update 2
fd01444 add README.md
3c76ad1 init

找到我们添加新功能时,当前分支所处的提交。假如是fd01444,那么我们接下来要做的操作就是将HEAD指针指向fd01444,也就是把我们当前分支已提交的内容重置到我们开发新功能之前的样子。我们需要运行下面的命令:

git reset fd01444 # fd01444是某次提交的hash值

如果没有指明重置的模式的话,默认会使用--mixed模式,这样的话我们在fd01444这次提交之后的所有提交都会被重置为没有提交的状态。接下来我们需要把这些新开发的功能迁移到一个新的分支。这时候我们可以使用下面的命令进行操作:

git checkout -b <newbranch>

这样我们就创建了一个新的分支,并且把新添加的功能也都迁移了过去,接下来就是常规的添加和提交操作了。

新功能需要添加在另一个分支上

如果我们需要把当前添加的新功能迁移到另一个已经存在的分支,那么我们需要做的前几个步骤跟上面的操作是一样的:

git log --oneline # 查找新功能开发之前的提交
git reset <commit hash> # 将当前分支重置到新功能开发之前的提交

接下来我们现在的状态就回到了新功能还没有提交的状态,那么就可以继续使用git stash相关的命令去操作了。

我们还有另外一个方法也能够将已提交到当前分支的功能添加到另一个分支上,那就是使用git cherry-pick命令。首先我们还是先用git log --oneline查找当前已提交的功能的hash值,然后切换到目标分支,运行命令:

git cherry-pick <commit hash>

这样就把我们在另一个分支开发的功能,添加到我们想要的分支了。如果有冲突的话,需要手动处理一下冲突。然后我们回到最初的分支,再次运行git reset <commit hash>命令,把已提交的内容进行重置,然后运行命令:

git checkout -- .

把当前分支没有添加到暂存区的内容都清除掉,这样也可以达到我们上面所说的,把新功能添加到另一个分支的目的。

新功能已经在本地提交了,且push到了远程仓库

第三种情况就是,我们已经把新开发的功能push到远程的仓库了,但是我们忽然发现新功能不应该在这个分支开发,我们这个时候应该怎么办呢?

首先我们应该保持当前的工作区是没有修改的,是一个干净的状态。不然使用撤销命令的时候会提示你需要把当前的文件内容变更先提交或者生成快照。当我们的工作区的状态是干净的时候,我们就可以进行撤销操作了。

首先需要知道我们应该撤销那一次提交的状态。使用git log --oneline查看要撤销的提交的索引,然后运行下面的命令:

git revert <commit>

这个时候命令运行的终端会进入编辑器模式,让你填写提交的信息。当然你也可以使用参数--no-edit这样就不会在进行撤销操作的时候打开编辑模式了。

如果需要撤销的提交比较多的话,我们可以使用..表示一个提交记录的范围。比如c1..c2就表示c2的可达提交,且排除c1的可达提交。所谓可达的提交指的是:提交本身及其祖先链中提交的集合

我们可以举个例子:

... a - b - c - d - HEAD

如果上面表示的是某个分支的提交记录,那么对于b..d表示的就是c d这两个提交,对于a..d表示的就是b c d这三个提交。如果大家想了解更多相关的内容,可以在git-rev-list这里深入的学习一下。

所以我们如果想快速的撤销一段范围的提交的话,可以运行类似下面这样的命令:

git revert 54dc134..a72d612 --no-edit

上述命令的54dc134就表示c1a72d612就表示c2--no-edit表明我们在运行撤销操作的时候不打开编辑模式。

我们如果需要对远程的分支进行撤销的话,首先考虑的就是使用git revert命令,因为git revert命令不会修改历史的提交记录,只是在原来的提交基础上添加新的提交,所以不会造成代码的丢失。在多人合作的情况下使用git revert命令撤销push到远程的操作还是很有必要的。

如果大家对于上面的这些问题有更好的解决方案的话,欢迎大家在文章下面留言,我们可以一起探讨一下,一起进步。如果你对文章有什么意见和建议的话也欢迎在文章下面留言,或者在这里提出来,我会持续努力改进的。也欢迎大家关注我的公众号关山不难越,及时获取最新的文章更新。

参考链接:

查看原文

赞 38 收藏 24 评论 2

dreamapplehappy 发布了文章 · 2020-08-24

想要在JS中把正则玩得飘逸,学会这几个函数的使用必不可少

image

在之前的一系列文章中,我们讲解了很多关于正则表达式的知识。那么作为一个前端工程师,如果想要把这些知识应用到我们平时的开发中去的话,就需要知道在JavaScript中,能够使用正则的函数有哪些?然后它们各自的功能是什么?有哪些需要注意的地方?只有掌握好了每一个方法的使用场景,我们才可能在需要使用的时候能够很快的想起来使用哪个方法效率最高,效果最好。

这些确实是一些基础的知识,但是我相信应该有很多同学还没有系统的把这些知识学习一边。相信我,如果你能够把这篇文章看完的话,你肯定可以学习到一些新的知识。知道每一个方法的用途,使用场景,学会在合适的场景选择合适的方法。当然你还能够掌握这些方法需要注意的地方,以防在以后使用的时候陷入了困境。

文章中的代码示例如果没有特别说明的话,都是在Chrome浏览器中进行的。本篇文章的内容比较长,建议先收藏起来,可以以后慢慢细看。

JavaScript中,能够使用正则表达式的函数有(排除了过时的方法):

RegExp.prototype

首先我们要讲解的是RegExp对象上的两个方法

RegExp.prototype.test()

  • 作用:检测给定的字符串中是否有满足正则的匹配
  • 代码示例

简单的匹配,根据匹配的结果确定是否匹配成功。

const reg = /\d{4}-\d{2}-\d{2}/;
const str1 = '2000-02-22';
const str2 = '20-20-20';
console.log(reg.test(str1)); // true
console.log(reg.test(str2)); // false

上面的正则表达式没有设置全局的标志符g,如果设置了全局的标志符的话,我们在使用这个方法的时候就要小心一些了。因为如果正则表达式设置了全局的标识符g,那么对于同一个正则表达式来说,在运行test方法的时候,如果匹配成功的话,它会修改这个正则对象的lastIndex属性,可能会在下次匹配的时候导致一些问题,我们下面来看一个例子

const reg = /abc/g;
const str1 = 'abcd';
const str2 = 'abcdabcd';

console.log(reg.lastIndex);  // 0
console.log(reg.test(str1));  // true
console.log(reg.lastIndex);  // 3
console.log(reg.test(str1));  // false

console.log(reg.lastIndex);  // 0
console.log(reg.test(str2));  // true
console.log(reg.lastIndex);  // 3
console.log(reg.test(str2));  // true

上面的例子很好地说明了这种情况,如果我们设置了全局标识符g的话,只要我们当前的匹配是成功的,那么接下来如果再次使用同样的正则进行匹配的话就可能会出现问题,因为上一个成功的匹配导致正则表达式对象的lastIndex属性的值发生了变化,那么下次进行匹配的时候是从lastIndex位置开始的,所以就可能会出现一些问题

  • 注意事项:如果在使用test方法的时候,需要注意正则表达式是否带有g标识符。如果这个正则表达式需要进行多次的匹配的话,最好不要设置g标识符。除非你知道自己确实需要这样做。
  • 使用场景

假如有这样一个需求,你需要判断用户输入的用户名是否满足需求,需求如下:(1)用户名长度需要是8-16位。(2)用户名可以包含数字,字母(大小写都可以),下划线。(3)数字和字母是必须包含的

当然对于熟悉正则表达式的你来说,这不是一个问题,能用一行代码解决的问题绝不用两行代码去解决。你可以很快可以通过使用test方法来解决这个问题。

const validNameRE = /^(?=_*(?:\d+_*[a-zA-Z]+|[a-zA-Z]+_*\d+))\w{8,16}$/;
// 假如这是用户输入的用户名
const userInputName = '1234567890';
// 检查用户输入的用户名是否合乎要求
const isValidName = validNameRE.test(userInputName); // false

在平时的开发中,如果需要判断页面所处的宿主环境的话,我们也会使用test方法去判断当前页面所处的环境。例如,你需要判断当前页面所处的环境是不是iPhone的话,你可能会写出这样的判断:

const iPhoneReg = /iPhone/;
console.log(iPhoneReg.test(navigator.userAgent));  // true

RegExp.prototype.exec()

  • 作用:这个方法是比较常用的一个方法,在给定的字符串中进行匹配,返回一个匹配的结果数组或者null。通常情况下我们会使用这个方法来提取字符串中符合匹配的一些字符串。
  • 代码示例

需要注意的是,如果没有符合的匹配,返回的结果是null,而不是一个空数组[]。所以当我们需要判断是否有匹配的结果的时候,不能凭感觉觉得返回的值是一个空的数组[]

const reg1 = /(\d{2}):(\d{2}):(\d{2})/;
const str1 = 'Sat Aug 22 2020 17:31:55 GMT+0800 (中国标准时间)';
const str2 = 'Sat Aug 22 2020';

console.log(reg1.exec(str1));  // ["17:31:55", "17", "31", "55", index: 16, input: "Sat Aug 22 2020 17:31:55 GMT+0800 (中国标准时间)", groups: undefined]
console.log(reg1.exec(str2));  // null

从上面的代码中我们可以看到,如果没有匹配结果的话,返回的结果是null。如果能够匹配成功的话,返回的结果是一个数组。在这个结果数组中,第0项表示正则表达式匹配的内容。其中第1..n项表示的是正则表达式中括号的捕获内容,对于上面的示例来说,第1..3项表示的是捕获时间的时分秒。数组还有额外的属性indexinput,其中index表示正则表达式匹配到的字符串在原字符串中的位置。input表示原始待匹配的字符串。

  • 注意事项

    • 注意正则表达式是否设置了g标识符,如果设置了g标识符,那么我们可以使用这个正则表达式进行全局的搜索。可以看下面的代码示例。
    const reg = /\d/g;
    const str = '654321';
    let result;
    while ((result = reg.exec(str))) {
      console.log(
        `本次匹配到的数字是:${result[0]}, 正则表达式的 lastIndex 的值是:${
          reg.lastIndex
        }`
      );
    }

输出的结果如下:

本次匹配到的数字是:6, 正则表达式的 lastIndex 的值是:1
本次匹配到的数字是:5, 正则表达式的 lastIndex 的值是:2
本次匹配到的数字是:4, 正则表达式的 lastIndex 的值是:3
本次匹配到的数字是:3, 正则表达式的 lastIndex 的值是:4
本次匹配到的数字是:2, 正则表达式的 lastIndex 的值是:5
本次匹配到的数字是:1, 正则表达式的 lastIndex 的值是:6

需要注意的是,如果上面匹配的正则表达式没有设置g标识符,或者在while循环的条件判断中使用的是正则表达式的字面量的话,都会造成“死循环”。因为那样的话,每次循环开始的时候,正则表达式的lastIndex属性都会是0,导致result一直都是有值的,所以就导致了“死循环”。所以我们在while循环中使用exec方法的时候一定要小心一些

  • 使用场景:这个方法主要用来在原始文本中提取一些我们想要的关键信息,所以只要是这样的一个需求场景,都可以使用正则表达式的exec方法去处理。比如:

    • 对用户输入内容中的链接进行自动识别,然后对相应的链接内容进行样式和功能上的处理。
    • 可以提取url中的查询参数,如果我们需要自己把url中的查询参数提取出来的话,使用exec方法也是一个选择。
    • 如果你阅读过vue的源码的话,在编译模块中的文本解析使用到了exec方法,有兴趣的话大家可以看一看相关的代码实现。

当然还有很多的场景可以使用exec方法去处理的,大家在平时的开发中有没有使用过exec方法处理一些问题呢?可以在下面留言,我们大家一起讨论一下,加深一下对这个方法的理解。

String.prototype

接下来我们来讲解一下String.prototype上面有关正则的一些方法。

String.prototype.match()

  • 作用:这个方法返回字符串匹配正则表达式的结果。
  • 代码示例
const reg = /\d/;
const str = 'abc123';
console.log(str.match(reg));  // ["1", index: 3, input: "abc123", groups: undefined]
  • 注意事项

    • 没有匹配到结果的返回结果是null
    const reg = /\d/;
    const str = 'abc';
    console.log(str.match(reg));  // null
    • 是否设置了g标识符,如果没有设置g的话,match的返回结果跟对应的exec的返回结果是一样的。如果设置了g标识符的话,返回的结果是与正则表达式相匹配的结果的集合。
    const reg = /\d/g;
    const str = 'abc123';
    console.log(str.match(reg));  // ["1", "2", "3"]
    • 如果match方法没有传递参数的话,返回的结果是[''],一个包含空字符串的数组。
    const str = 'abc123';
    console.log(str.match());  // ["", index: 0, input: "abc123", groups: undefined]
    • 如果match方法传递的参数是一个字符串或者数字的话,会在内部隐式调用new RegExp(regex),将传入的参数转变为一个正则表达式。
    const str = 'abc123';
    console.log(str.match('b'));  // ["b", index: 1, input: "abc123", groups: undefined]
  • 使用场景

    简单获取url中的查询参数:

    const query = {};
    // 首先使用带有g标识符的正则,表示全局查找
    const kv = location.search.match(/\w*=\w*/g);
    if (kv) {
      kv.forEach(v => {
          // 使用不带g标识符的正则,需要获取括号中的捕获内容
        const q = v.match(/(\w*)=(\w*)/);
        query[q[1]] = q[2];
      });
    }

String.prototype.matchAll()

  • 作用:这个方法返回一个包含所有匹配正则表达式以及正则表达式中括号的捕获内容的迭代器。需要注意的是这个方法存在兼容性,具体内容可以查看String.prototype.matchAll
  • 代码示例
const reg = /(\w*)=(\w*)/g;
const str = 'a=1,b=2,c=3';
console.log([...str.matchAll(reg)]);

String.prototype.matchAll()

  • 注意事项

    • match方法相同的地方是,如果传递给matchAll方法的参数不是一个正则表达式的话,那么会隐式调用new RegExp(obj)将其转换为一个正则表达式对象。
    • 传递给matchAll的正则表达式需要是设置了g标识符的,如果没有设置g标识符,那么就会抛出一个错误。
    const reg = /(\w*)=(\w*)/;
    const str = 'a=1,b=2,c=3';
    console.log([...str.matchAll(reg)]);  // Uncaught TypeError: String.prototype.matchAll called with a non-global RegExp argument
    • 在可以使用matchAll的情况下,使用matchAll比使用exec方法更便捷一些。因为在全局需要匹配的情况下,使用exec方法需要配合循环来使用,但是使用matchAll就可以不使用循环。
    • matchAll方法在字符串执行匹配的过程中,正则表达式的lastIndex属性不会更新。更多详情可以参考String.prototype.matchAll()
  • 使用场景

还是以上面的获取url中的查询参数这个小功能来实践一下:

const query = {};
const kvs = location.search.matchAll(/(\w*)=(\w*)/g);
if (kvs) {
    for (let kv of kvs) {
        query[kv[1]] = kv[2];
    }
}
console.log(query);

String.prototype.replace()

  • 作用:这个方法在平时的开发中应该比较常用,那么它的作用就是使用替换物replacement替换原字符串中符合某种模式pattern的字符串。其中替换物可以是一个字符串,或者返回值是字符串的函数;模式可以是正则表达式或者字符串。
  • 代码示例

    因为这个函数的入参可以是不同的类型,所以对每种类型的入参我们都来实践一下吧。

    • pattern是字符串,replacement也是字符串。这种形式在平时的开发中使用的比较多。
    const pattern = 'a';
    const replacement = 'A';
    const str = 'aBCD';
    console.log(str.replace(pattern, replacement));  // ABCD
    • pattern是正则表达式,replacement是字符串
    const pattern = /__(\d)__/;
    const replacement = "--$$--$&--$`--$'--$1--";
    const str = 'aaa__1__bbb';
    console.log(str.replace(pattern, replacement));  // aaa--$--__1__--aaa--bbb--1--bbb

如果replacement是字符串,那么在这个字符串中可以使用一些特殊的变量,具体可参考Specifying a string as a parameter

  • pattern是正则表达式,replacement是函数

    const pattern = /__(?<number>\d)__/;
    const replacement = function(match, p1, offset, str, groups) {
      console.log(`匹配到的字符串是:${match}\n捕获到的内容是:${p1}\n匹配的位置是:${offset}\n原始待匹配的字符串是:${str}\n命名的捕获内容是:${JSON.stringify(groups)}`);
      return '======';
    };
    const str = 'aaa__1__bbb';
    console.log(str.replace(pattern, replacement)); // aaa======bbb

其中控制台的输出如下所示:

匹配到的字符串是:__1__
捕获到的内容是:1
匹配的位置是:3
原始待匹配的字符串是:aaa__1__bbb
命名的捕获内容是:{"number":"1"}

如果你对replacement是函数这种情况不是很了解的话可以看看Specifying a function as a parameter,里面会有详细的解释,这里就不在具体解释了。

  • 注意事项

    需要注意的地方就是当我们的pattern是正则表达式的时候,要注意是否设置了g标识符,因为如果没有设置g标识符的话,只会进行一次匹配。设置了g标识符的话,会进行全局的匹配。

  • 使用场景

    对于前端来说,对用户的输入进行校验时很常见的需求。假如我们有一个输入框,只允许用户输入数字,我们可以这样处理:

    const reg = /\D/g;
    const str = 'abc123';
    console.log(str.replace(reg, ''));  // 123

    这样就能够保证用户的输入只有数字了。

String.prototype.replaceAll()

As of August 2020 the replaceAll() method is supported by Firefox but not by Chrome. It will become available in Chrome 85.

这个方法和replace方法的作用差不多,从名字上就能够知道replaceAll是全局的替换。因为这个方法的兼容性问题,我们需要在Firefox浏览器上进行试验。

const pattern = 'a';
const replacement = 'A';
const str = 'aBCDa';
console.log(str.replace(pattern, replacement));  // ABCDa
console.log(str.replaceAll(pattern, replacement));  // ABCDA
  • 注意事项:如果给函数传递的pattern参数是个正则表达式的话,这个正则表达式必须设置了g标识符,不然会抛出一个错误。
const pattern = /a/;
const replacement = 'A';
const str = 'aBCDa';
console.log(str.replace(pattern, replacement));  // ABCDa
console.log(str.replaceAll(pattern, replacement));  // Uncaught TypeError: replaceAll must be called with a global RegExp

String.prototype.search()

  • 作用:这个方法用来在字符串中寻找是否含有特定模式的匹配,如果找到对应的模式,返回匹配开始的下标;没有找到的话返回-1
  • 代码示例
const reg = /\d/;
const str1 = '123';
const str2 = 'abc';
console.log(str1.search(reg));  // 0
console.log(str2.search(reg));  // -1
  • 注意事项

    • 如果传入的参数不是一个正则表达式的话,会隐式的调用new RegExp(regexp)将其转换为一个正则表达式。
    • 没有找到相应匹配的时候,返回的值是-1;所以大家在使用这个方法做判断的时候要注意,只有返回值是-1的时候,才表示没有找到相应的匹配。
  • 使用场景

如果你需要找到特定匹配在字符串中的位置的话,那么可以使用search方法。

const reg = /\d/;
const str = 'abc6def';
console.log(str.search(reg));  // 3

String.prototype.split()

  • 作用:将一个字符串按照分割器进行分割,将分割后的字符串片段组成一个新的数组,其中分割器separator可以是一个字符串或者一个正则表达式。
  • 代码示例

    • 分割器separator是字符串:
    const str = 'hello, world!';
    console.log(str.split(''));  // ["h", "e", "l", "l", "o", ",", " ", "w", "o", "r", "l", "d", "!"]
    • 分割器separator是正则表达式:
    const str = 'abc1abc2abc3';
    const separator = /\w(?=\d)/;
    console.log(str.split(separator));  // ["ab", "1ab", "2ab", "3"]
  • 注意事项

    • 如果split方法没有传递参数的话,会返回一个包含原字符串的数组:
    const str = 'hello, world!';
    console.log(str.split());  // ["hello, world!"]
    • 因为JavaScript的字符串是使用UTF-16进行编码的,该编码使用一个16比特的编码单元来表示大部分常见的字符,使用两个编码单元表示不常用的字符。所以对于一些不常用的字符来说,在使用split方法进行字符串分割的时候可能会出现一些问题:
    const str = '😀😃😄😁😆😅';
    console.log(str.split(''));  // ["�", "�", "�", "�", "�", "�", "�", "�", "�", "�", "�", "�"]

如何解决这种类型的问题呢?第一种方法是使用数组的扩展运算符:

const str = '😀😃😄😁😆😅';
console.log([...str]);  // ["😀", "😃", "😄", "😁", "😆", "😅"]

第二种方法是使用设置了u标识符的正则表达式:

const str = '😀😃😄😁😆😅';
const separator = /(?=[\s\S])/u;
console.log(str.split(separator)); // ["😀", "😃", "😄", "😁", "😆", "😅"]
    • 如果传入的正则表达参数中含有捕获的括号,那么捕获的内容也会包含在返回的数组中:
    const str = 'abc1abc2abc3';
    const separator = /(\w)(?=\d)/;
    console.log(str.split(separator));  // ["ab", "c", "1ab", "c", "2ab", "c", "3"]
    • split方法还可以传入第二个参数,用来控制返回的数组的长度:
    const str = 'hello, world!';
    console.log(str.split('', 3));  // ["h", "e", "l"]
    • 使用场景

    在实际的开发中,最常用的场景就是将一个字符串转换为一个数组了:

    const str = 'a/b/c/d/e';
    console.log(str.split('/')); // ["a", "b", "c", "d", "e"]

    总结

    当我们能够把上面的这些方法都熟练的掌握之后,那么在实际的开发中再结合正则表达式来使用的话,那简直就是如虎添翼,能够在一些场景下提高我们开发的效率。

    当然光靠看看文章是不能够很好地将这些知识点都记牢固的,你需要的是一个一个的实践一下,这样才能够加深自己的记忆,才能够记得更牢固

    如果大家还想了解更多关于正则表达式的知识点的话,可以看看我之前写的一系列的文章:

    如果你对本篇文章有什么意见和建议,都可以直接在文章下面留言,也可以在这里提出来。也欢迎大家关注我的公众号关山不难越,学习更多实用的前端知识,让我们一起努力进步吧。

    查看原文

    赞 35 收藏 30 评论 0

    dreamapplehappy 发布了文章 · 2020-08-04

    报告老板,我们的H5页面在iOS11系统上白屏了!

    0802.png

    时间回到一周前,当时刚开发完公司A项目的一个新的版本,等待着测试完成就进行发布。此时的我也准备从连续多日的紧张开发状态中走出来,以为可以稍稍放松一下。而那时的我还不知道,我即将面临一个强大的Bug选手,更不知道我要跟这个Bug来来回回进行多次的搏斗。当然,我们能看到这篇文章也就说明了我最终解决了这个Bug,而且这个过程也是相当的精彩的。什么?你不相信,那就让我来带你进入这个“跌宕起伏”的经历中吧。

    友情提示:接下来的文章也许有一点长,但是希望你能够坚持读下去。我相信我在解决这个Bug的过程中的一些思路会给你带来一些思考。当然也希望你在这个过程中能够像我一样学习到一些新的知识,为以后排查类似的Bug积累一些经验。好啦,话不多说,让我们开始吧。

    项目介绍

    先来简单介绍一下A项目,这是一个基于Vue框架的项目,项目使用的也是Vue CLI这个开发工具。这个项目是需要集成在别的APP中的,也就是页面需要在APP中进浏览和操作。这个项目在我接手之前已经开发过一段时间了。所以项目中的一些依赖库和工具库版本相对比较低,这也给我后续的调试以及解决Bug的过程增加了一些困难。

    BUG初现

    当时开发完成之后,就交给我们这边的测试和另一个城市的相关同学去验收这次开发的功能。在我们这边一切都很正常,测试这边也没有反馈有什么问题。但是在另一个城市的同学小C的iPhone手机上却发现了白屏,打开页面之后什么内容也没有。

    发现了这个问题之后,我再次跟我们这边的测试同学确认了一下,看看我们这边测试的iOS系统的iPhone手机有没有这个问题。经过测试的测试,发现我们这边的几台iPhone手机都没有问题。然后就问了小C他使用的测试手机的系统版本是多少,当时感觉应该跟iOS的系统版本有关系。

    小C反馈说他的iPhone是6S Plus,然后系统的版本是11.1.2。我问了我们这边测试使用的iPhone版本都是多少,测试反馈说系统的版本都是12以上的。所以到这里,我确定了这个白屏Bug的出现肯定跟iPhone手机的系统有关系

    重现BUG之路

    虽然确定了问题出现的环境,但是因为我身边没有系统是11的iPhone手机,所以想让这个问题重现就变成了一个难题。询问了身边的同事,大家的系统版本也都普遍高于12,所以借用别人的手机进行调试这个方法暂时也不可行。

    在平时的开发中,如果网页在iOS系统的APP中有一些问题的话,我们一般都会通过Safari浏览器进行调试。但是因为这次出现问题的iPhone手机不在我这里,并且我这边也没有相同系统的手机。所以想通过真机进行调试就不太可能了。那怎么办呢?这个问题肯定是要解决的,我也相信办法总比困难多

    想要进行调试,最简单的办法就是让我有一个系统是11的iPhone手机。所以我就搜索看看有没有什么办法可以给iPhone手机安装11的系统。一搜索还真的有,过程也不算是很复杂。但是其中有一个步骤是需要到一些论坛或者第三方的助手网站下载跟自己手机型号相匹配的iOS系统,这个步骤让我有点感觉不安全。毕竟不是官方的,不能够保证安全性。而且也未必有版本是11的系统。所以这个方案就暂时作罢

    在我搜索的过程中,我发现有网友说可以使用Xcode安装相应系统版本的iPhone模拟器来进行调试。哎,你说我怎么没有想到这个办法呢?这确实是一个不错的办法。因为之前跟公司的同事学习过Swift,也了解过Xcode的一些操作。突然感慨,真是技多不压身,你不知道你什么时候就会用上你学过的知识。所以有条件的话,还是多学习一些知识。额,有点跑题了。

    安装Xcode

    我打开公司的电脑,开始安装Xcode,但是发现公司的电脑系统版本太低,安装Xcode需要升级系统,所以没办法,先升级系统吧。因为升级的时间比较长,我想到自己家中的Mac电脑上是有安装过Xcode,所以决定先回家。留下公司的电脑慢慢升级。

    回到家,二话不说就开始准备调试,但是发现我的Xcode上面的iPhone模拟器的系统版本也都是12以上的,查了一下资料,Xcode是可以安装不同系统版本的模拟器的,于是我就安装了系统版本是11的模拟器。这个过程需要我们打开Xcode的偏好设置,然后在Components选项中,选择下载你要安装的对应系统版本的模拟器。

    安装iOS11的模拟器

    安装成功之后,运行iPhone 6S Plus模拟器,使用模拟器的Safari打开h5的页面地址,果然是白屏。

    iPhone 6S Plus模拟器出现白屏

    小样,终于把这个问题给复现了,这样就距离解决这个Bug不远了。我打开MacSafari浏览器,进入开发者模式,发现了如下所示的报错

    Safari浏览器控制台的报错

    我搜索了一下这个错误,发现是因为项目中使用了...ES6扩展运算符,然后iOS 11系统不支持这个这个运算符。这么容易就找到问题了,开心。想到这个问题还是比较好解决的,可以通过使用Babel的一些插件,很容易就可以将这个问题解决掉。然后我就开心的睡觉去了,心想这个问题也不是什么大问题,明天处理一下就好了。

    安装Safari Technology Preview

    第二天到公司,我就在项目中的babel的配置文件中添加了相应的插件

    {
      ...  // 省略原来的配置内容
      "plugins": ["@babel/plugin-proposal-object-rest-spread"]
    }

    然后发布到测试环境中。告诉了小C同学再次测试一下,我也在等着解决这个Bug的好消息。但是,出现的却不是好消息,小C给我回复说还是不可以。什么,不可能呀,我就马上用公司的电脑再次进行测试。当我用公司电脑的Safari调试系统是iOS 11iPhone 6S PLus模拟器的时候,却发现出现了下面这个情况:审核警告:“data-custom”太新,无法在此检查的页面上运行

    审核警告:“data-custom”太新,无法在此检查的页面上运行

    我就又搜索了一下为什么会出现这个问题,终于让我找到了答案Safari浏览器的Web Inspector工程师也说这是一个Bug,不过他们已经修复了,在下个发布的版本中就可以正常使用新的Safari浏览器去调试比较老的iOS系统的模拟器了。知道现在这个版本的Safari调试不了模拟的iOS 11系统的页面。我有点沮丧,总不能我现在回家把我的电脑拿过来吧😂?当我想着该如何解决的时候,我发现了上面那个回答中提到了Safari Technology PreviewSafari技术预览

    stackoverflow上面Safari浏览器的Web检查器的开发者的回复

    我看这个名字感觉有点希望,然后就搜索了一下Safari Technology Preview是什么。然后就发现它相对于Safari就跟Chromium相对于Chrome是一样,都相当于是开发版本的浏览器。

    Safari Technology Preview

    这时,我觉得可以使用Safari Technology Preview进行调试。所以就下载了Safari Technology Preview,当我打开Safari Technology Preview然后进入开发者模式后,发现确实可以调试iOS 11系统的页面。然后我就看了一下为什么还是白屏的问题。发现出现的错误还是上次的问题:

    SyntaxError: Unexpected token '...'. Expected a property name.

    也就是说这个问题还没有解决掉,因为打包后的代码是没有SourceMap的,所以要想看更详细的报错信息,需要在本地进行调试。本地的环境中是有SourceMap的,可以定位到更详细的错误信息,我在本地运行了项目,然后我打开了控制台的错误详情,发现是使用的一个第三方的库出现了问题。

    找到了出现问题的使用的第三方库

    那么到这里为止,可以说明上面我们使用的Babel插件没有处理这个第三方的库,所以现在我们的问题就变成了:如何解决第三方库中出现的...扩展运算符没有被编译为ES5语法的问题

    将第三方库中的ES6语法进行编译

    查看Vue CLI中相关的配置方法

    这时我又仔细的看了一下Vue CLI的相关文档,发现确实在浏览器的兼容性这个章节中,提到了一些处理的方法。原来我们在项目中写的代码默认会帮我们转换为ES5的语法的,但是如果项目中依赖的第三方库需要polyfill的话,那需要我们手动进行配置。一看到这里,我感觉黎明就要来了

    Vue CLI浏览器兼容性

    我就开始尝试这三种方法。我发现第一种方法是比较简单的,也很好配置。于是我就尝试了第一种方法。在项目的vue.config.js中添加如下的配置:

    ...  // 省略的配置
    transpileDependencies: [
      'module-name/library-name' // 出现问题的那个库
    ],
    ...  // 省略的配置

    重新运行项目,当我将要为即将到来的成功欢呼鼓掌时,控制台突然报告了如下的错误:
    Uncaught TypeError: Cannot assign to read only property 'exports' of object '#<Object>'

    Uncaught TypeError: Cannot assign to read only property ...

    这个报错是在Chrome浏览器的控制台出现的,因为项目在本地重新运行之后会首先打开Chrome浏览器。真是的,一个问题还没有解决,又出来了一个新的问题。然后再次查询资料后发现,原来是因为这个第三方的库是一个CommonJS类型的库,而Babel默认处理的是ES6module类型的库,所以这里就又出现了新的问题。

    https://github.com/webpack/webpack/issues/4039 sokra的回复

    第一种方法遇到了阻碍,先暂停一下。我准备继续尝试下面两种方法。但是因为后面两种方法对原来的项目改动有点大,所以我直接通过Vue CLI创建了一个新的项目,在package.json中加入项目中使用的那个第三方包的依赖,使用公司的包管理工具安装了依赖。然后运行项目,打开控制台确实发现了相同的错误。但是打开详情以后,发现出错的路径跟我原来项目不一致。然后我这次抱着试一试的心态,继续使用了第一种方法尝试看看可不可以。然后复制了出错路径的包名称,在vue.config.js文件中的对应位置添加了如下的配置代码:

    ...  // 省略的配置
    transpileDependencies: [
      'module-name-new/library-name-new' // 出现问题的那个库
    ],
    ...  // 省略的配置

    然后重新运行项目,发现居然可以了。啊,居然可以了。为什么我在原来的项目中这样却不可以呢?我看了一下原来项目的依赖以及现在新的测试项目的依赖,发现它们的vue, babel版本差了好多。我猜测可能是因为这个原因。但是现在肯定不可以贸然升级这些依赖的版本,因为为了解决这个问题再次带来新的问题就得不偿失了。

    还有一个问题就是为什么同样的第三方库,在原来的项目中和现在的项目中报错的路径不一样。而且看着像是使用了两个不一样的第三方库。这里先留个悬念,我会在后面的文章中进行解释。

    接下来,我开始在测试项目中继续尝试剩下的两种方法,对于第二种方法,因为老项目中使用的presets是没有polyfills这个配置选项的,到现在为止出问题的这个第三方库我不知道除了这个...对象扩展操作符之外还有没有别的依赖。所以这个方法我暂时也放弃了。

    对于第三个方法,我觉得可以尝试,首先我将测试项目中的一些关键依赖进行了手动降级,然后按照上面的第三个方法的步骤在测试项目中使用。但是发现测试项目运行之后,提示需要安装core-js,安装core-js之后还报错,再次提示需要安装es.module.regex.match等等很多依赖,继续查资料,发现需要把配置中的 useBuiltIns修改,但是因为我接手的这个项目是老项目,依赖比较多,不确定修改useBuiltIns这个配置选项后会不会出现新的问题。所以也不敢贸然修改这个配置选项,所以也暂时放弃了这个方法。

    我后来想了一下,对于...扩展运算符来说,这是一个新的语法。是不能够通过一些polyfills去解决的。需要Babel对这个语法进行编译,然后才可以在低版本的系统中使用,所以解决的办法还是要让Babel对这个库再次进行编译。

    寻找新的突破口

    当进行到了这里的时候,似乎没有了出路。一时间我感觉我要被这个Bug打败了,我似乎听到了它无情的嘲笑,“小伙子,是不是被我折磨的没有脾气啦;放弃吧,你是没办法打倒我的。哈哈哈。。。

    Photo by sebastiaan stam on Unsplash

    但是,它看错我了,Bug越是难解决,我对它就越有兴趣。所以我决定好好理一下思路,准备再次扬帆起航。

    我发现第一种办法其实是起作用的,只不过是因为一个是CommonJS类型的,一个需要是ES6 module类型的。所以我决定从这个地方入手,于是我决定查查相关的资料,看看Babel有没有办法可以即能够处理CommonJS模块,又能够处理ES6 module模块呢?终于,功夫不负有心人,我发现了Babel里面有这么一个配置sourceType,如果把sourceType设置为unambiguous就可以解决这个问题

    https://babeljs.io/docs/en/options#sourcetype

    这样Babel就会根据模块文件中有没有import/export来决定使用哪种解析模块的方式。于是我再次使用了第一种方法,在vue.config.js中添加了transpileDependencies选项的配置,然后在项目中的Babel配置文件中添加了如下的配置:

    module.exports = {
      ...  // 省略的配置
      sourceType: 'unambiguous',
      ...  // 省略的配置
    };

    发现的确可以,这一刻成功的喜悦再次降临。然后我再次打包,再次把代码部署到测试环境,赶忙让小C同学再次测试一下,发现的确可以。欧耶,终于解决这个问题了。我终于可以松一口气了,哈哈哈。。。小样,这怎么会难得到我呢?

    但是,当我仔细阅读将这个选项设置为unambiguous时,我发现了一些问题。因为这样的话会有一些风险,因为就算不使用import/export语句的这些模块也可能是完全有效的ES6 module,所以这样的话就有可能会出现一些意外的情况。怎么办,我似乎在一不留神的时候又被Bug卡住了脖子

    https://babeljs.io/docs/en/options#sourcetype

    我觉得老天总是给我开玩笑,当我从一个坑里跳出来,以为没有危险的时候。前面突然又多出来一个坑,我一不留心就又掉了进去。我感觉既然都走到了这里,肯定要继续走下去,一定有办法可以优化我现在遇到的问题。我就很仔细的再次看了一下Babel的配置说明文档,这个时候就心想如果我对Babel再熟悉一些就好了。没关系,继续努力。终于,我似乎看到了什么了不得的配置选项。

    https://babeljs.io/docs/en/options#overrides

    我在Config Merging options里发现了overrides选项,这个配置选项不正是我需要的吗?我可以利用这个配置选项将我需要的第三方包使用unambiguous的处理方式,然后其他的第三方库都按照之前的方式处理不就可以了。哈哈哈,我真是个天才,我心里这样对自己说😂。

    Photo by bruce mars on Unsplash

    所以只需要在项目的babel.config.js中写下如下的配置就可以了:

    module.exports = {
      ...  // 省略的配置
      overrides: [
        {
          include: './node_modules/module-name/library-name/name.common.js',  // 使用的第三方库
          sourceType: 'unambiguous'
        }
      ],
      ...  // 省略的配置
    };

    对了,还有一件事情还没有说,那就是上文提到的关于为什么使用公司自己的包管理工具下载下来的node_modules包的名称跟使用官方的npm包管理工具下载的包的名称不一致的问题。原因是公司使用的包管理工具是cnpm的一个修改版本。又因为cnpm为了提高下载的速度,使用了cnpm/npminstall,所以才会出现下载的包名比较混乱的情况,详情可以看这里

    到此完结撒花,总结一下:出现白屏的原因是因为使用的第三方库的包中使用了...扩展运算符,然后因为第三方的包默认是没有被Babel处理过的,所以在不支持...iOS 11系统上就出现了白屏。解决的方式就是通过给vue.config.js的配置文件中transpileDependencies配置选项中添加上出问题的包的名称就可以了。当然如果项目比较老,可能还需要像文章上面写的那样的处理方式。

    解决这个Bug过程就像是升级打怪一样,不断失败,不断尝试,只要不放弃,终有成功的那一天。如果你坚持看到了这里,那说明你也很棒呀。在当今这个信息爆炸的时代里,能够坚持看完一篇很长的文章已经很不错了。

    一点反思与思考:这个过程中我也发现了自己对BabelVue CLI其实没有那么熟练,如果我对它们比较熟练的话,那我解决这个Bug应该会花费更少的时间。当然,现在把它们学习好也不算晚。要抱着学习的态度,这次解决这个Bug的过程,就是我以后解决其它类似Bug的经验。还有在解决Bug的这个过程中要有耐心,当然在尝试之后也要学会放弃错误的方向

    写这篇文章也花费了我不少的时间,如果你有所收获或者感悟,不妨点赞,转发,收藏走一波,这个要求应该不算过分吧😂?

    如果你对本篇文章有什么意见和建议,都可以直接在文章下面留言,也可以在这里提出来。也欢迎大家关注我的公众号关山不难越,学习更多实用的前端知识,让我们一起努力进步吧。

    公众号:关山不难越

    查看原文

    赞 21 收藏 10 评论 10

    dreamapplehappy 发布了文章 · 2020-07-28

    想写出效率更高的正则表达式?试试固化分组和占有优先匹配吧

    20200719 (1).png

    上次我们讲解了正则表达式量词匹配方式的贪婪匹配和懒惰匹配之后,一些同学给我的公众号留言说希望能够快点把量词匹配方式的下篇也写了。那么,这次我们就来学习一下量词的另外一种匹配方式,那就是占有优先的匹配方式。当然我们这篇文章还讲解了跟占有优先匹配功能一样的固化分组,以及使用肯定的顺序环视来模拟占有优先的匹配方式以及固化分组。准备好了吗,快来给自己的技能树上再添加一些技能吧。

    我们如果可以掌握这种匹配方式的原理的话,那么我们就有能力写出效率更高的正则表达式。在进行深入的学习之前,希望你至少对正则表达式的贪婪匹配有所了解,如果还不怎么了解的话,可以花费几分钟的时间看一下我上一篇关于贪婪匹配和懒惰匹配的文章。

    占有优先的匹配方式:(表达式)*+

    首先,占有优先的匹配方式跟贪婪匹配的方式很像。它们之间的区别就是占有优先的匹配方式,不会归还已经匹配的字符,这点很重要。这也是为什么占有优先的匹配方式效率比较高的原因。因为它作用的表达式在匹配结束之后,不会保留备用的状态,也就是不会进行回溯。但是,贪婪匹配会保留备用的状态,如果之前的匹配没有成功的话,它会回退到最近一次的保留状态,也就是进行回溯,以便正则表达式整体能够匹配成功
    那么占有优先的表示方式是怎样的?占有优先匹配就是在原来的量词的后面添加一个+,像下面展示的这样。

    .?+
    .*+
    .++
    .{3, 6}+
    .{3,}+

    因为正则表达式有很多流派,有一些流派是不支持占有优先这种匹配方式的,比如JavaScript就不支持这种匹配的方式(前端同学表示不是很开心?),但是我们可以使用肯定的顺序环视来模拟占有优先的匹配。PHP就支持这种匹配方式。所以接下来我们一些正则的演示,就会选择使用PHP流派进行演示。

    我们来写一个简单的例子来加深一下大家对于占有优先匹配方式的了解。有这么一个需求,你需要写一个正则表达式来匹配以数字9结尾的数字。你会怎么写呢?当然,对于已经有正则表达式基础的同学来说,这应该是很容易的事情。我们会写出这么一个正则表达式\d*9,这样就满足了上面所说的需求了。

    d*9

    让我们把上面的贪婪匹配方式修改为占有优先的匹配方式,你觉得我们还能够匹配相同的结果吗?来让我们看一下修改后的匹配结果。

    d*+9

    答案是不能,你也许会好奇,为什么就不可以了。让我来好好给大家解释一下为什么不能够匹配了。

    我们知道正则表达式是从左向右匹配的,对于\d*+这个整体,我们在进行匹配的时候可以先把\d*+看作是\d*进行匹配。对于\d*+这部分表达式来说它在开始匹配的时候会匹配尽可能多的数字,对于我们给出的测试用例,\d*+都是可以匹配的,所以\d*+直接匹配到了每一行数字的结尾处。然后因为\d*+是一个整体,表示占有优先的匹配。所以\d*+匹配完成之后,这个整体便不再归还已经匹配的字符了。但是我们正则表达式的后面还需要匹配一个字符9,但是前面已经匹配到字符串的结尾了,再没有字符给9去匹配,所以上面的测试用例都匹配失败了。

    在开始匹配的过程中我们可以把占有优先当做贪婪匹配来进行匹配,但是一旦匹配完成就跟贪婪匹配不一样了,因为它不再归还匹配到的字符。所以对于占有优先的匹配方式,我们只需要牢记占有优先匹配方式匹配到的字符不再归还就可以了。

    固化分组:(?>表达式)

    我们了解了占有优先的匹配之后,再来看看跟占有优先匹配作用一样的固化分组。那什么是固化分组呢?固化分组的意思是这样的,当固化分组里面的表达式匹配完成之后,不再归还已经匹配到的字符。固话分组的表示方式是(?>表达式),其中里面的表达式就是我们要进行匹配的表达式。比如(?>\d*)里面的表达式就是\d*,表示的意思就是当\d*这部分匹配完成之后,不再归还\d*已经匹配到的字符。

    所以,对于\d*+来说,我们如果使用固化分组的话可以表示为(?>\d*)。其实,占有优先固化分组的一种简便的表示方式,如果固化分组里面的表达式是一个很简单的表达式的话。那么使用占有优先量词,比使用固化分组更加的直观。

    我们将上面使用占有优先的表达式替换为使用固化分组的方式表示,下面两张图片展示了使用固化分组后的匹配结果。

    (?&gt;d*)

    (?&gt;d*)9

    还有一些需要注意的是,支持占有优先量词的正则流派也支持固化分组,但是对于支持固化分组的正则流派来说,不一定支持占有优先量词。所以在使用占有优先量词的时候,要确保你使用的那个流派是支持的。

    使用肯定顺序环视模拟固化分组:(?=(表达式))1

    对于不支持固化分组的流派来说,如果这些流派支持肯定的顺序环视捕获的括号的话,我们可以使用肯定的顺序环视来模拟固化分组。如果对于正则表达式的环视还不熟悉的话,可以花几分钟的时间看一下我之前写的这篇文章距离弄懂正则的环视,你只差这一篇文章,保证你可以快速的理解正则的环视。

    看到这里,你可能要问,为什么肯定的顺序环视可以模拟固化分组呢?我们要知道固化分组的特性就是匹配完成之后,丢弃了固化分组内表达式的备用状态,然后不会进行回溯。又因为环视一旦匹配成功之后也是不会进行回溯的,所以我们可以利用肯定的顺序环视来模拟固化分组。

    我们可以使用(?=(表达式))\1这个表达式来模拟固化分组(?>表达式)。我来解释一下上面这个模拟的正则表达式,首先是一个肯定的顺序环视,需要在当前位置的后面找到满足表达式的匹配,如果找到的话,接下来\1会匹配环视中已经匹配到的那部分字符。因为在顺序环视中的正则表达式不会受到顺序环视后面表达式的影响,所以顺序环视内部的表达式在匹配完成之后不会进行回溯。然后后面的\1再次匹配环视里面表达式匹配到的内容,这样就模拟了一个固化分组。

    我们再将上面的表达式替换为使用模拟固化分组的方式表示,下面两张图片展示了使用模拟固化分组后的匹配结果。

    (?=(d*))1

    (?=(d*))19

    模拟的固化分组在效率上要比真正的固化分组慢一些,因为\1的匹配也是需要花费时间的。不过对于贪婪匹配所造成的的回溯来说,这点匹配的时间一般还是很短的。

    贪婪匹配和占有优先效率的比较

    我们上面说过,因为占有优先不会回溯,所以在一些情况下,使用占有优先的匹配要比使用匹配优先的匹配效率高很多。那么下面我们就使用代码来验证一下贪婪匹配和占有优先匹配的效率是怎样的。

    代码如下所示:

    // 匹配优先(贪婪匹配)匹配一行中的数字,后面紧跟着字符b
    const greedy_reg = /\d*b/;
    // 占有优先(使用肯定顺序环视模拟)
    const possessive_reg = /(?=(\d*))\1b/;
    // 测试的字符串 000...(共有1000个0)...000a
    const str = `${new Array(1000).fill(0).join('')}a`;
    
    console.time('匹配优先');
    greedy_reg.test(str);
    console.timeEnd('匹配优先');
    
    console.time('模拟的占有优先');
    possessive_reg.test(str);
    console.timeEnd('模拟的占有优先');

    在上面的测试代码中,我们生成了一个长度为1001的字符串,最后一位是一个小写字母a。因为贪婪匹配在匹配到最后一个数字后,发现最后一个字符是a,不能够满足b的匹配,所以开始进行回溯。虽然我们知道就算进行了回溯也不会匹配成功了,但是运行的程序是不知道的,所以程序会不断的回溯,一直回溯到\d*什么也不匹配,然后再次检查b,发现还是不可以匹配。最终报告匹配失败。中间进行了大量的回溯,所以匹配的效率降低了。

    对于占有优先的匹配,在第一次\d*匹配成功后,发现后面的a不能够满足b的匹配,所以立即报告失败,匹配效率比较高。但是因为JavaScript不支持占有优先固化分组,所以我们使用了肯定的顺序环视来替代,但是因为\1需要进行接下来的匹配,也会消耗一些时间。所以这个测试的结果不能够严格意义上表明占有优先贪婪匹配在这种情况下的效率高,但是如果模拟的占有优先消耗的时间比较短,那就可以说明占有优先确实比贪婪匹配在这种情况下的效率高。

    我首先在node.js环境中运行,我本地的node.js版本为v12.16.1,系统为macOS。程序运行的结果如下:

    匹配优先: 1.080ms
    模拟的占有优先: 0.702ms

    这个结果只是其中一次的运行结果,运行很多次后发现匹配优先的耗时要比我们模拟的占有优先多一些,但也有几次的运行时间是小于模拟的占有优先的。我把相同的代码也放在了Chrome浏览器中运行了多次,发现匹配优先的耗时有时比模拟占有优先高,有时比模拟占有优先低,不是很好做判断。

    JavaScript中不能够很好地反应这两种匹配方式的效率高低,所以我们需要在PHP中再次进行试验。因为PHP是原生的支持占有优先匹配的,所以比较的结果是有说服力的。我们使用PHP的代码实现上面相同的逻辑,代码如下:

    // 贪婪匹配
    $greedy_reg     = '/\d*b/';
    // 占有优先
    $possessive_reg = '/\d*+b/';
    
    // 待测试字符串
    $str = implode(array_fill(0, 1000, 0)) . 'a';
    
    // 计算贪婪匹配花费的时间
    $t1 = microtime(true);
    preg_match($greedy_reg, $str);
    $t2 = microtime(true);
    echo '贪婪匹配运行的时间:' . ($t2 - $t1) * 1e3 . 'ms';
    
    echo PHP_EOL;
    
    // 计算占有优先匹配花费的时间
    $t3 = microtime(true);
    preg_match($possessive_reg, $str);
    $t4 = microtime(true);
    echo '占有优先匹配运行的时间:' . ($t4 - $t3) * 1e3 . 'ms';

    可以看到运行的结果如下:

    贪婪匹配运行的时间:0.025033950805664ms
    占有优先匹配运行的时间:0.0071525573730469ms

    如果你将这段代码运行多次的话,可以看到占有优先匹配所花费的时间的确是比贪婪匹配要少一些的,所以上面的代码可以说明,在这种情况下占有优先匹配的效率是比贪婪匹配的效率高的。

    关于正则表达式的占有优先匹配和固化分组的讲解到这里就结束啦,如果大家有什么疑问和建议都可以在这里提出来。欢迎大家关注我的公众号关山不难越,我们一起学习更多有用的正则知识,一起进步。

    参考资料:

    查看原文

    赞 11 收藏 6 评论 2