阅读视图

发现新文章,点击刷新页面。

2023 年度总结

前言

今年无疑是我最摆烂的一年,竟然整整一年没有更新过博客,所以上一篇文章还停留在《2022 年度总结》

着实惭愧,是因为我今年变懒了吗?是也不是,但可以说今年是我参加工作以来,变化最大的一年。

一段奇妙的经历

年后复工不久,我所在的项目组就被解散了,团队成员接连离职,一时间变得很迷茫,不知道该何去何从。

那段时间心态有点蚌埠住,这打乱了我的计划,导致晚上经常失眠,所以下班后,索性也不去想太多、开摆!

长期以来,我发现每当自己在工作中面对具有挑战性的工作或能从中得到满足感时,便会激发学习和写作的动力。反之,如果在工作缺乏挑战性和无趣,就会陷入消极的摆烂状态。对于这种现象,我称之为:「动力反馈循环」。

这种现象既有好的一面也有不好的一面,但它确实让我摆烂了一段时间。

但在某天,事情发生了变化,机缘巧合下,我为帮助一位朋友解决他所面临的问题,用相近一个小时写了个插件。出乎意料的是,这个插件竟然在接下来的数个月里为我带来了远超日常工资的额外收入。

这个插件技术含量并不高,对任何程序员来说都是易如反掌的事情,但对普通人来说,它能让成本降低无数倍,所以他们拥有强烈的付费意愿,最终一传十、十传百,很快,我的微信好友从 200 人不到变成 1000+。

我承认这完全是靠运气,并不意味着我拥有多厉害的商业洞察力,更无法保证能够复刻商业模式,一切皆运气。

我知道这只是一项短暂的副业,不具有持续性。因此,当收入开始下滑时,我便决定将插件出售。

在这过程中,最大的收获肯定是金钱,我从来没有想象过,赚钱竟然如此容易。

请原谅我这种没见过世面的人借用并改编一下罗格那句话:「睡觉睡到自然醒, 收钱收到手抽筋」。

但除此之外,我还收获了什么呢?我认为,首先要明白了一个鸡汤道理:

成功的关键也许不是英明的决策,运气也起着关键作用。然而,这种运气并非完全偶然,它是你日常积累的结果。为了在机遇到来时能够比别人更快地抓住它,关键仍在于持续的付出和努力。

我以前也开发过类似的插件,但未曾考虑过收费。以往,能够获得满足感的事情就是别人的答谢以及小额红包,这是我第一次尝试盈利,没想到效果出奇地好。当你要考虑对产品进行收费时,不妨先问一下自己是否愿意付费。

还有不必一开始追求完美,试想一小时不到写出来的插件肯定很粗糙,甚至是半自动的,但并不妨碍它正常工作。正如 Randy 所说:自动化可以做也必须做,但不是从 day one 就开始做。

在没有摸清楚客户的习惯前,就贸然通过现有思维去解决问题,哪怕做得再多,方向错了,也许都是徒劳。

举个例子,如果我们要搞个下单或会员功能,可能大家都会想着弄个小程序或网站,让客户自己来操作。然而事实上客户并不买单,你要相信并理解,哪怕现在都 2024 年了,还真有人用不来这些东西。客户作为衣食父母,有时你要兼容他们,在这些客户的思维里,远不如直接微信转账给你来得方便,而不是一个冷冰冰的小程序。

所以上面那句「收钱收到手抽筋」可不是开玩笑的。想想看,如果每天有几百人微信转账给你,但每笔就几块钱…

当然即便是这样的局面,也是有方法可以在不改变客户习惯的前提去实现自动化的,而此时做自动化才是真正有价值的,但已经不是一开始想象的那样客户通过小程序充值云云这种思路,所以关键点仍是让客户能够接受。

以上是我觉得除了金钱以外,更为宝贵的经验,它可以让我在下一次做出更多正确的选择,少踩一些坑。

送自己的礼物

今年,我给自己送了两份礼物。首先,在年初,我决定换一台电脑。在综合考虑了 M1 Pro 和 M2 Pro 之后,我最终选择了 M1 Pro,配置为 16GB RAM + 512GB 存储,通过 PDD 的百亿补贴以 10500 元的价格入手,香的嘞。一年的使用下来,它的性能完全满足我的需求,从未出现过卡顿,是我今年最满意的购物之一。

第二个礼物是我心动很久的小牛 U+B 电动车,虽然花 6、7k 买一台代步的电动车似乎过于奢侈,但实际使用后,其实非常值得。每天骑它上下班,不仅让我的心情变得格外愉快,还拓大了我的生活半径,生活幸福感拉满。

迈入新篇章

是的,今年我完成了一件人生大事——我成家了!虽然按照中国男人普遍结婚年龄,我这个年纪结婚似乎稍早了些,但每个人的情况都是不同的。对于我而言,现在正是承担家庭责任的最佳时机。并且我和她已经走到了第七个年头(嗯,从初恋走到结婚,稳如老狗),其实我们的关系早已超越了恋人的层面,即便没有领证,我们就已经生活得如同一对老夫妻。

结婚是比较烧钱的,尽管广东这边的婚礼相对全国其他地区来说开销小一些,但仍然把我的积蓄花光。对于我这种社恐人来说,并不热衷于摆酒席,但迫于传统观念,也就不得不循规蹈矩,整体下来的感受就是:累,很累。

思想的转换

今年我感觉自己变了不少。以前跟朋友聊天,一旦意见不合,我心里就不是滋味,有时候还得辩到底,好像非赢过他们不可。但现在回头看,那样其实挺没意思的,也挺幼稚。我为啥非得那样呢?其实就是想让别人觉得我对。但现实是,每个人都是在自己的小世界里长大的,看问题的角度自然不同。我之前在 V2EX 上看到不顺眼的观点,直接拉黑,现在想想,那其实挺狭隘的。人的看法是会变的,今天我不同意的人和观点,或许哪天就能给我带来新的启发。所以,现在我不再那么轻易拉黑别人了,毕竟,谁知道明天他们会不会说出让我眼前一亮的话呢?

就像以前,很多事情发生了我都会气得不行,但慢慢地,随着岁月添皱,我开始意识到事情没那么简单,也许并不像我当初想的那样。所以现在,遇到曾让我难以忍受的事,我学会了保持冷静,用一颗平和的心去看待。

但我也在想,这会不会是变相的犬儒?是不是我开始对事情不那么上心了,或者这只是逃避的一种方式?想了想,明白即使要冷静面对,也不能丢了追求正义的心。犬儒可能让人变得冷漠,但我还是希望,在看清世界的复杂后,依旧能带着热情去做一些改变。认识到世界不是非黑即白,不意味着我们就不追求更好。

还有对于写博客这件事情,虽然确实是我今年变懒了,但更大的原因,每当我想写一点什么,内心深处总会有一个声音告诉自己,也许你只是在胡说八道,索性就不写了。当然,这种想法并不好,我希望 2024 年能克服它。毕竟,我早就认为写博客主要是为了给将来的自己看,同时能够给别人一些帮助,所以我要继续坚持下去。

工作上的感悟

前面提到,今年年初我曾经陷入一段时间的摆烂状态,但这种状态并没有持续太久。尽管今年的工作并不在我的计划之中,但也可以说是意外地收获了一些经验。让我先交代一下,在我初入职场时有幸加入到某公司的前端基建团队,致力于提升开发效率等方面的工作。这段经历对我后来的每一份工作都产生了深远的影响。然而,由于我一直没有真正参与产品线的开发,我的视野在很大程度上局限于提升效率这个领域,而没有形成更全面的产品思维。因为当时我的用户主要是开发同事,难免会受到开发角度的限制。虽然我在技术上能够优雅地解决问题,效率也很高,但所做出的东西往往对用户不够友好。这一问题在我自己开发的几个小项目中尤为明显。

因此,后来我选择了一份负责 2C 项目的岗位,真正地接触用户。在这个过程中,我学到了很多东西。例如,以前我并不具备像素眼,但随着被设计师盯着改样式的次数增加,我开始具备了这个能力。我也学会了站在用户的角度去思考功能的实现。然而,2C 项目往往版本迭代非常紧凑,我们不得不在优雅的代码实现和功能实现之间做出取舍。虽然在参与这个项目的过程中,我也努力解决了一些团队协作和开发效率上的问题,但要理解,很多时候同事们可能会固步自封,习惯了现状。在一个公司范围内推广新的方案,但如果没有领导的支持,是非常艰难的。

然而,今年我们所在的项目组被解散了。虽然我原本的计划没有完全实现,但我却重新承担了前端团队基建的工作。时隔数年,我再次投入到这个领域,让我在原本思考如何优雅实现的基础上,学会了更多关于如何设计的知识。因此,我认为这次经历是因祸得福。在从事产品线工作两年后,我发现我更喜欢做这类基础设施的工作。

最后,我想分享一个小插曲。在年底公司评优时,决策层在我和另一位候选人之间,选择了那位能为公司带来更多资金收益的同事。虽然我内心有些小失望,但我能理解这个决策。同时,我认为我仍然有机会继续从事我喜欢且擅长的工作,而且我的产出也得到了领导的认可,这对我来说是不亚于公司层面的认可。

2024 年

简而言之,今年是我变化很大的一年,但好的习惯却没有坚持下来,希望在来年能够重新拾起来。

最终,祝新年快乐!

2022 年度总结

前言

这篇总结在去年 12 月份底拖到现在,后面做了一个不大不小的手术,过年期间连床都不想下,就一直拖到了现在,最近已经恢复得七七八八,于是趁 1 月份还未结束,抓紧时间把它赶出来。

2022 年已经结束,感觉这一年过得特别漫长。这是疫情席卷全球的第三年,而且仍然是大事频出的一年。如果让我总结 2022,我认为有几句话是值得永远铭记的。与《人民日报》所选出的 12 句中没有提及苦难的话不同,我认为以下几句话才是正确的集体记忆:

第一句是「这个世界不要俺了」,我自认为不是一个政治冷感的人,但是我几乎不参与网上讨论,只是每当有重大的社会事件发生时,都很难不去关注。

第二句话是「我们是最后一代」,与之对应的是「他的软肋是他儿子」,让我不禁想起黄子华曾在《秋前算账》说过这么一句话:如果大家都不生孩子,暴政必亡!但我们知道这是不可能的,人类是大自然的动物,繁衍是人类的天性,即使在最困难的时候,人类也没有放弃生育。很多人都把希望寄托在下一代,但我认为,如果自己都做不到的事情,凭什么觉得自己的孩子能做到呢?无论是否丁克,我们都应该表示尊重和理解。

第三句话是「请抑制灵魂对自由的渴望」,有句话说过:不自由毋宁死。但实际上,能做到这一点的人并不多。大多数人都愿意为了安全和方便而放弃一些自由,只要不触及生而为人的底线。大多数人也都默认了这一点,只是当这句话从他们口中说出来时,让人感到无比心寒。

回顾 2021 年的总结,我的愿望只有一个——“好好活着”。这看上去似乎是一个非常简单的愿望,但对于某些人来说,能够活着就是最大的奢望。疫情已经放开,希望 2023 年一切都会回到正轨,我相信念念不忘,必有回响。

收获和成长

技术层面:

  1. 通过阅读开源代码解决心中疑惑,「源码面前,了无秘密」。
  2. 通过阅读 ECMAScript 规范使 JavaScript 水平更上一层楼。
  3. 通过开发油猴插件提高日常繁琐任务的效率。

个人影响力:

  1. 成为自己日常使用的工具的 Contributor。
  2. 办了一份技术周刊,扩大了幸运表面积(Luck Surface Area)。

在今年年初,突发奇想办了一个周刊,不过持续了 14 周就没有下文了,主要原因是那时候有点忙,因为维护一个周刊,要么需要阅读大量的文章,要么需要有深度的思考,并且一周一次,需要占据很多时间,因此一旦忙起来就很容易断更,一旦断更,就很难再恢复之前的热情,不过总体来说,我认为维护一个周刊对自身是很有帮助,因此今年会重新上路,但形式不再局限与之前那样,打算写一些较有深度的长文或系列文章。

年度盘点

最喜欢的书

今年看过的书依旧不多,其中比较喜欢的这几本:

  • 《翦商》,一部夏商周启示录,喜欢阅读历史的看官可不要错过了。
  • 《精通正则表达式》,无论看多少文档都比不上这本书给你带来的对于正则的领悟。
  • 《编程语言的设计与实现》,Ruby 作者向你展示创建编程语言的乐趣。

最喜欢的音乐

今年一如既往喜欢听我逼哥的《梵高先生》,有一天我听到了义乌隔壁酒吧的版本,直接猛男落泪!

  • 《梵高先生》
  • 《处处吻》
  • 《可惜我是水瓶座》

2023?

今年希望在技术上有所精进,还需要拓展一下技术以外的视野,比如多从产品角度思考问题。

想要实现的东西:

  • 自动格式化 wxml 文件的 prettier plugin

当然,好好活着是最重要的,希望今年一切都会好起来!

我们如何从 Wxml2Canvas 迁移到 Painter

路漫漫其修远兮

糖纸苦 Wxml2Canvas 久矣!

长期以来,糖纸项目使用 Wxml2Canvas 库来生成分享海报。这个库的功能就是将 Wxml 转换成 Canvas,并最终生成一张图片。但是,这个库非常不稳定,经常会出现各种奇怪的 BUG,只能说勉强能用。如果你想了解 Wxml2Canvas 给我们带来的痛苦,可以阅读这篇文章:《一行 Object.keys() 引发的血案》

因此,我们一直希望能找到一个更好的替代方案。在社区搜索后,我们发现 Painter 非常不错。然而,它与 Wxml2Canvas 的使用方式有很大的差异,我们的项目中有二十多个地方使用了 Wxml2Canvas,所以迁移起来并不容易。但 2022 即将结束,我们希望能在最后时刻做点事情来让自己找回一丝慰藉,所以才有了这篇文章。

让我们来看看这两个库的使用方式有什么不同:

image-20221227005600071image-20221227005620310

Wxml2Canvas 使用方式相对直观,使用 Wxml 和 Wxss 实现,而 Painter 则使用 JSON 配置。如果要将项目迁移到 Painter,就需要手写大量的 JSON 配置,这需要相当多的工作量。

吾将上下而求索

俗话说得好:只要思想不滑坡,办法总比困难多!

那么,有没有一种方法可以让我们迁移到 Painter,同时又不用重写 JSON 配置呢?

让我们从不同的角度思考一下:Wxml2Canvas 可以直接将 Wxml 画到 Canvas 上,那么是否也可以将其转换成 JSON 配置呢?这样,我们就可以复用现有的 Wxml 代码,减少迁移的成本。

大致流程如下:

image-20221227222820467

总之,我们需要一个转换器来将 Wxml 转换为符合 Painter 使用的 JSON 配置,我愿称之为 Wxml2Json。

说干就干,我们可以直接照搬 Wxml2Canvas 的做法。首先获取最外层容器的尺寸,用来定义分享海报的宽高。然后,通过 wx.createSelectorQuery().selectAll() 获取所有需要绘制的节点和样式信息。接着,根据不同的节点类型设置对应的属性,最终输出一份 JSON 配置供 Painter 使用。

其核心方法是 getWxml,大致实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
getWxml({container, className} = {}) {
const getNodes = new Promise(resolve => {
query
.selectAll(className)
.fields(
{
id: true,
dataset: true,
size: true,
rect: true,
computedStyle: COMPOUTED_ELEMENT_STYLE,
},
res => {
resolve(this.formatNodes(res))
},
)
.exec()
})

const getContainer = new Promise(resolve => {
query
.select(container)
.fields(
{
dataset: true,
size: true,
rect: true,
},
res => {
resolve(res)
},
)
.exec()
})

return Promise.all([getContainer, getNodes])
}

formatNodes 方法的职责就是根据需要绘制的节点类型进行格式转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
formatNodes(nodes) {
return nodes
.map(node => {
const {dataset = {}} = node

node = {...node, ...dataset}

const n = _.pick(node, ['type', 'text', 'url'])

n.css = this.getCssByType(node)

return n
})
.filter(s => s && s.type)
}

有了这个转换器,我们的迁移工作只需要将 new Wxml2Canvas 替换成 new Wxml2Json ,然后将数据传入 Painter 中即可。因此,一天内完成所有 Wxml2Canvas 迁移到 Painter 的工作将不再是个梦。

山重水复疑无路

缝合结束,不出意外的话马上要出意外了,虽然大部分机型都表示情绪稳定,但成功路上注定不会一马平川。

果不其然,让全网「沸腾」的鸿蒙首当其冲,如下图所示:
image-20221228005732730

然后,测试小姐姐的 iPhone 12 也毫不甘落下风,上来就憋了个大招:微信闪退。

以上这两个页面都有一个共同点,就是生成的分享海报尺寸非常大,比如说这个:1170 × 17259。

我去线上看了一下,发现同一个页面上 Wxml2Canvas 却是稳定的,那这个 Painter 为什么这么拉胯?

开始找茬,分析两者的实现,终于发现了一些端倪:首先是 wx.canvasToTempFilePath 的参数不同:

image-20221228223957183

翻看 wx.canvasToTempFilePath 文档,其中 xy 默认值都是 0,问题不大。

主要问题在于 widthheight,我们先来看看 wx.canvasToTempFilePath 这几个参数的作用:

  • width,画布的宽度
  • height,画布的高度
  • destWidth,输出图片的宽度,默认值是 width × dpr
  • destHeight,输出图片的高度,默认值是 height × dpr

然后再梳理一下这两个库中的参数值是多少:

  • Wxml2Canvas
    • width:与外层容器的宽度、 canvas 宽度一致
    • height:与外层容器的高度、 canvas 高度一致
    • destWidth,width × dpr
    • destHeight,height × dpr
  • Painter
    • width:外层容器的宽度 * dpr、 canvas 宽度一致
    • height:外层容器的宽度 * dpr、 canvas 高度一致
    • destWidth,与 canvas 宽度一致
    • destHeight, 与 canvas 高度一致

答案呼之欲出了,我来解释一下:

  1. Painter 会将所有需要绘制的节点尺寸乘以设备的 dpr。假设我们要生成一张 375 x 800 的海报,其中包含一张 100 x 100 的图片,在当前设备的 dpr 为 3 的情况下,Painter 会创建一张 1125 x 2400 的画布,在画布上绘制一张 300 x 300 的图片。最终在保存图片时,输出的图片尺寸与画布大小完全一致。
  2. Wxml2Canvas 在绘制时是创建一张 375 x 800 的画布,并在画布上绘制一张 100 x 100 的图片,但是在最终保存图片时,输出的图片尺寸是画布大小乘以 dpr。

看上去 Painter 的做法似乎并无不妥,因为画布大小和最终成品是 1:1 的;反观 Wxml2Canvas 却是 1:3,难道这样导出的图片不会影响清晰度吗?我们直接来做个实验,分别用 Painter 和 Wxml2Canvas 生成同一张分享海报,对比两张图片的不同,结果发现导出的图片无论尺寸还是文件大小都是一模一样的,如图所示:

image-20221229181609765

柳暗花明又一村

既然如此,我们就可以直接将 Wxml2Canvas 的方案移植到 Painter,最终发现这样能 work:

image-20221229132803803

总而言之,尽管两者最终生成的成品尺寸是一样的,但是 Painter 设置的画布尺寸比 Wxml2Canvas 大了三倍,这样会使用更多的内存,而且微信官方文档也提到:设置过大的宽高会导致 Crash 的问题。

经过这一番操作,鸿蒙和 iPhone 12 也终于服帖了。然而,又有新的问题出现了。当某个页面生成并保存图片后,在滑动该页面时会明显感觉卡顿,对比一下 fps(帧率)的变化,确实离谱。
image-20230103233431180

这种卡顿是肉眼可见的,猜测可能是因为内存泄露造成。在真机上调试分析了一下内存占用情况,未进行生成海报时,CPU 占用率为 2%,内存占用为 872 MB:

image-20230103235024011

当生成海报时,CPU 占用率快速飙升到 22%,内存占用 895 MB:

image-20230103235506588

随后发现内存占用并没有下降,直到我们离开了当前页面时,占用率才有所下降。

image-20230103235744395

既然如此,可以在生成海报之后立即对分享卡片的内存进行回收,最简单的方式就是使用 wx:if 控制。

1
2
3
4
<share-card 
+ wx:if="{{showShareCard}}"
id='share-card'
/>

最后来晒晒战绩,迁移后生成时间缩短近 50%:

综上所述,Wxml2Canvas 在稳定性和可维护性方面都有所欠缺,但也有值得 Painter 借鉴的地方。例如,Wxml2Canvas 的使用方式更直观,不需要设置过大的画布尺寸,从而避免了 Crash 的风险。因此,将两者缝合起来,以最小的成本提高糖纸生成分享海报的效率和稳定性,何乐而不为?

关于二舅的一些感悟

最近二舅的视频炒得沸沸扬扬,看完视频后的我和大部分人的反应一样,觉得非常感动。

但随后我又不禁陷入沉思,如果换成是我,我会怎么做?

思索片刻,无非两条路,第一:结束自己的生命,但如果这么做,除了你的家人,还有谁为你感到伤心呢?

君不见,这段时间在微信群流传不少跳楼的视频,大家看完后有什么反应?惊叹一声,继续埋头做自己的事。

有谁会去深究他们背后的不幸呢,也许这个人就是另一个二舅,只是他选择走上这一条。

但我们没有反应,这是为什么呢?我们会认为,面对苦难,我们更应该像二舅这样积极乐观,而不是选择放弃。

是的,但这么多不幸的人,除了少部分选择结束自己的生命,其他人,哪个不是和二舅一样?

他们选择活下去,也只能是以积极乐观的态度面对生活,否则早就走上那条路了,所以麻烦搞清楚,这是一种无可奈何,试问换成你经受着一切,让你侄子拍个视频,让广大网友来敬佩你、在弹幕里疯狂刷「二舅」,你愿意吗?

二舅的不幸,是个人的不幸,更是时代的不幸,纵观他的人生,就是一部新中国史。

如同《活着》里面的福贵,他活着,也仅仅是活着,我们不会去赞扬他的乐观态度,我们更应该感到悲哀,为里面的福贵悲哀,也为这个时代悲哀,但记住永远不要相信这些苦难都是值得的,苦难就是苦难,苦难不会带来成功,苦难不值得追求,磨练意志只是因为苦难无法躲开。

王小波说:“人是一种会骗自己的动物,我们吃了很多无益的苦,虚掷了不少年华,所以有人就想说,这种经历是崇高的。”

二舅这个视频的意义是什么?难道让大家看看作者的二舅是如何坚强,就能治好我们所谓的精神内耗?

视频中暴露了一些真正应该被解决的问题,大家有重视吗?为什么二舅的残疾证一直办不下来,是我们相关部门为了磨炼二舅经受苦难的能力吗?二舅是否有低保?一个 68 岁的老人拉着另一个 88 岁的老人干活很正常吗?在我们社会中,还有多少个这样的二舅?

一个普通老百姓通过视频诉说自己的苦难,熬一碗鸡汤给自己喝,那也是因为二舅只能认命了呀,有什么办法?

可是官方凑热闹的时候是否应该反省一下,二舅的苦难,有多少是因为您造成的?您是不是想说:你们老百姓没办法,我们也觉得没办法呀,大家忍忍就过去了是吧?

反驳一下作者的话,人生比「把一手烂牌打成好牌」更重要的是,先搞清楚到底是谁在发牌,否则人家一直给你发烂牌,你即便打两辈子都打不完,反观人家周公子天生好牌,想怎么打就怎么打,这公平吗?

话说回来,上一次 B 站的视频炒得沸沸扬扬还是《后浪》,还记得当时豪情万丈、生长在这里实在是太幸福啦,我们有选择的权利!也才两年时间,画风就变成了像二舅这样平平淡淡凄凄惨惨切切才是真,这是同一批人吗?

从一次 yarn 命令死循环说起

前言

最近有个想法,希望在一个 yarn workspace 项目中实现任意一个子包中安装依赖时,都执行一些类似于初始化、同步配置的动作。

然而在操作过程中遇到了一个关于 yarn --cwd 有趣的问题,特地记录下来,希望能对后来者有所帮助。

遇到什么问题呢

先交代一下我们项目的基本情况,它是一个通过 yarn workspace 管理的 monorepo 项目,使用的是 yarn v1.22.11 版本,目录结构大致如下:

1
2
3
4
5
6
7
8
monorepo
├── package.json
├── app-a
│   └── package.json
├── app-b
│   └── package.json
└── config
   └── package.json

其中 app-aapp-b 都使用了 config 这个共享包:

1
2
3
"dependencies": {
"@monorepo/config": "../config",
}

我们需要在根目录的 package.json 中的 preinstall 钩子做一些初始化操作:

1
2
3
"scripts": {
"preinstall": "./bin/init.sh",
}

此时我们在根目录执行 yarn 或者 yarn add <pkg-name>,都会触发 preinstall 这个钩子,但在 app-a 中执行 yarn是不会触发根目录的 preinstall 钩子的。

因此,我们需要分别在每个子包上都加上这行,也即在每个子包安装依赖时都执行一下根目录的 preinstall 命令:

1
2
3
"scripts": {
"preinstall": "yarn --cwd ../ preinstall",
}

于是,奇怪的事情就发生了,当我在 app-a 中执行 yarn 的时候,它停留在安装 @monorepo/config 的阶段,同时我的电脑明显变得卡顿,于是打开 htop 一看,好家伙,满屏都是:

1
4ark   40987  26.3  0.5 409250368  78624   ??  R  8:36下午   0:00.09 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall

CPU 占用率直接达到 100%,吓得我赶紧 kill 掉这些进程:

1
ps aux | grep preinstall | awk '{print $2}' | xargs kill -9

分析原因

惊吓过后,来分析一下原因,很显然这段命令陷入了死循环,导致越来越多进程,于是尝试在每个子包中都手动执行一遍 yarn --cwd ../ preinstall 后,发现一切正常,那问题出在哪呢?

于是我再执行了一遍 yarn,并且用以下命令将进程信息复制出来,以便分析:

1
ps -ef | pbcopy

随后验证我刚刚的猜测,的确是这个命令在不断触发自己,导致死循环:

1
2
3
4
5
UID   PID  PPID   C STIME   TTY     TIME CMD
501 50399 50379 0 8:50下午 ?? 0:00.10 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall
501 50400 50399 0 8:50下午 ?? 0:00.11 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall
501 50401 50400 0 8:50下午 ?? 0:00.11 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall
501 50402 50401 0 8:50下午 ?? 0:00.12 /usr/local/bin/node /usr/local/bin/yarn --cwd ../ preinstall

由于三个分包执行的命令都一样,不清楚是不是由于某个分包引起,于是修改一下命令以便区分:

1
2
3
"scripts": {
"preinstall": "echo app-a && yarn --cwd ../ preinstall",
}

随后发现问题是出现在 config 这个子包,于是我把这个子包的 preinstall 命令去掉,果然没有这个问题了,非常奇怪。

难道是 --cwd ../ 这个路径有问题?验证一下,把命令改成这样:

1
2
3
"scripts": {
"preinstall": "pwd && yarn --cwd ../ preinstall",
}

发现 pwd 输出是这样子的:

1
/4ark/projects/monorepo/app-a/node_modules/@monorepo/config

从这里的输出我们发现了两个问题,第一个问题是:

  • yarn workspace 共享包的 preinstall 被执行的时候,其实已经被拷贝到 app-anode_modules 中,而不是在当前目录,因此 --cwd ../ 并不指向项目根目录。

这一点比较好理解,毕竟 config 作为一个依赖包,确实应该被拷贝到应用的 node_modules

而第二个问题就不太理解了,为什么明明设置了 --cwd ../,却依然在当前目录执行呢?按照预期 cwd 的指向应该是:

1
/4ark/projects/monorepo/app-a/node_modules/@monorepo

难道是我对 cwd 参数的理解有偏差?看一下 yarn 的文档中对 cwd 描述:

Specifies a current working directory, instead of the default ./. Use this flag to perform an operation in a working directory that is not the current one.

This can make scripts nicer by avoiding the need to cd into a folder and then cd back out.

从文档的描述来看,cwd 的作用不就是代替 cd 吗,但现在的结果看来 yarn --cwd ../ preinstall 并不等价于 cd ../ && yarn preinstall

这就不得不让人疑惑 cwd 的定位方式了,在网上搜寻一番没找到相关的讨论,那只能自己动手丰衣足食,直接从 yarn 源码中寻找答案。

分析源码

前面我们说到,我们使用的是 yarn v1.22.11,在 yarn 的 GitHub 仓库中发现 v1 版本的最新版本停留在 v1.23.0-0,那我们就从这个版本的源码来进行分析,首先克隆代码到本地:

1
git clone --depth=1 https://github.com/yarnpkg/yarn

然后安装依赖并运行起来:

1
yarn && yarn watch

这时候它就会自动监听代码修改然后重新编译,我们查看 package.json 发现 yarn 的 bin 主要是调用 ./bin/yarn.js:

1
2
3
4
"bin": {
"yarn": "./bin/yarn.js",
"yarnpkg": "./bin/yarn.js"
},

也就是我们直接执行 bin/yarn.js 的效果就如同执行 yarn,试一下查看版本:

1
2
> /Users/4ark/projects/yarn/bin/yarn -v
1.23.0-0

PS:当然你也可以在项目目录下使用 npm link 把它挂载到本地中。

接下就是一番调试,终于定位到可以回答我们疑问的代码,在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function findProjectRoot(base: string): string {
let prev = null;
let dir = base;

do {
if (fs.existsSync(path.join(dir, constants.NODE_PACKAGE_JSON))) {
return dir;
}

prev = dir;
dir = path.dirname(dir);
} while (dir !== prev);

return base;
}

const cwd = command.shouldRunInCurrentCwd ? commander.cwd : findProjectRoot(commander.cwd);

可以看到 cwd 的定位方式是从当前目录寻找是否存在 package.json,若存在,则返回此目录,否则将目录经过 path.dirname 处理一遍,继续寻找,直到寻找到最外层。

那么这里最关键的是 path.dirname 的返回值,我们先看一下文档对于它的描述:

The path.dirname() method returns the directory name of a path, similar to the Unix dirname command. Trailing directory separators are ignored,

就是返回一个路径中的目录部分,作用与 unix 下的 dirname 命令一致,通常是这么使用的:

1
2
3
4
5
> dirname /4ark/app/index.js
/4ark/app

> dirname /4ark/app/packages/index.js
/4ark/app/packages

是不是会肤浅地认为它的作用就是返回一个路径的上一级目录?如果传入的是一个绝对路径,确实可以这么肤浅地认为,然而当传入的是一个相对路径时,情况就不一样了:

1
2
3
4
5
6
7
8
> dirname ../app/index.js
../app

> dirname ../../
../

> dirname ../
问: 会返回什么呢?

答案是:.,也就是当前目录。

那这里就能回答我们之前的问题,为什么在 node_module/@monorepo/config 中使用 yarn --cwd ../ preinstall 却在当前目录执行,因为它的上一级 node_modules/@monorepo 不存在 package.json,所以经过 dirname ../ 处理后 cwd 的指向就是当前目录。

如果对 node.js 中 path.dirname 的实现方式感兴趣,可以看这里 path.js#L538-L554

解决方案

摸清楚原因后,那解决这个问题也不是难事,只要我们把相对路径改成绝对路径,是不是就能解决这个问题了?

思考一下,其实 yarn --cwd ../ preinstall,把 ../ 改成绝对路径行不行呢?比如在本文的场景,../ 其实就是项目的根目录,那我们完全可以通过别的方式获取到项目的根目录,比如 在 git 中:

1
git rev-parse --show-toplevel

所以,我们把命令改成这样,问题就迎刃而解了:

1
2
- yarn --cwd ../ preinstall
+ yarn --cwd $(git rev-parse --show-toplevel) preinstall

那就不得不提一下,其实在 yarn v2 中新增了一个 --top-level 属性,它的作用刚好就是为了解决这个问题。

结语

其实我们再回过头来想,在本文的例子中,根本不需要在 config 目录中添加 preinstall 这个钩子,因为它作为共享包,每次修改都必然要在其它使用这个包的地方,重新安装一次,所以只要确保这些地方会执行 preinstall 就可以了,那也就意味着不会出现本文遇到的问题。

不过,多踩坑也不是坏事,只要搞清楚背后的原因,问题也就不是问题。

周刊第14期:暂停更新说明、自动化测试的未来趋势

暂停更新

因为最近在做一个 Side Project,所以本周刊已经有一段时间没更新了,毕竟一个人的时间精力是非常有限的,无法同时把两件事情做好,思前想后,决定先暂时停止周刊,等后面时间充裕起来,再恢复周刊,请见谅。

本周见闻

为什么会有「她」和「祂」

在 V2EX 看到一个帖子《汉字不分性别的“他”》,不禁疑惑为何要将「他」分为「它、他、她、牠、祂」呢?

首先为什么会出现「她」呢,其实「她」的开始是从清朝的刘半农才开始使用的,在以前「她」一直是「姐」的异体字,而在 1934 年的女性杂志《妇女共鸣》中,就曾在一篇启事指出:「本刊同仁,以人字旁代男子、女字旁代女子,牛字旁代物件,含有侮辱女子非人之意」,所以拒绝用「她」字。但尽管当时饱受批评,如今「她」还是成为流行的女性专用代词。

而「祂」的出现则是西方宗教在华传教时,用作对上帝、耶稣等的第三人称代名词,不过在我们生活中已经很少能够见到了。

延伸阅读:《「他」、「X也」,还是「Ta」:非二元性別代词有哪些?》

一些 tips

分享两篇非常不错的文章,分别提供 Bash 和 HTML 很多有用的技巧,这里就不全文摘抄了,有兴趣可以点击进原文查看。

5 个现代 Bash 语法

处理输入比 Python 和 Node.js 更加简单

在 Bash 中,你可以通过以下代码来获取用户输入:

1
2
3
4
5
6
7
read -p "Enter your name: " name
echo "Hello, $name"

# 示例
> ./test.sh
Enter your name: 4Ark
Hello, 4Ark

10 个罕为人知但非常有用的 HTML 提示。

打开摄像机

我们可以通过 input 的 capture 属性来打开摄像机,它具有两个属性值:

  1. user:前摄像头
  2. environment:后摄像头
1
<input type="file" capture="user" accept="image/*">

分享文章

自动化测试的未来趋势

这篇文章主要讲述自动化测试的发展以及未来趋势,从最早期的录制回放技术开始,逐步发展成DOM对象识别与分层自动化,而如今火热的 AI 技术会给自动化测试带来哪些突破呢?

其实在业界中已经有基于 AI 技术的自动化测试技术:

  1. 自愈(Self-Healing)技术
  2. 机器学习(Machine Learning)技术

自愈技术一般指的是:一种自我修复的管理机制。
举个例子,假设我们通过 Cypress 等框架进行 E2E 测试时,都是通过 CSS 选择器等方式获取元素,从而做进一步的测试,而当我们的内部实现发生变化时(这里指的就是元素发生变化),测试用例会失败,我们需要手动修改测试用例。
而自愈技术可以通过比较页面前后的差异,来自动修复测试用例中的 CSS 选择器,并在结束时更新测试用例到代码中。
自愈技术在业界较好的实践是 Healenium

然而传统的基于元素定位器等方式,面临着一些问题:

  • 仍然需要人工获取定位方式;
  • 如果是通过 Canvas 绘制出来的对象,如何识别元素 (如Flutter Web)。

于是就有了机器学习来解决这个问题,它可以通过图像识别和处理等技术来生成测试用例,比如直接根据某个按钮的截图来定位这个按钮,现在在业界较好的实践是 Airtest

自动化测试未来趋势不仅仅是这两种,还有如智能化探索性测试,智能遍历测试以及智能验证等。关于智能遍历所用到的技术,大家可以参考DQN的介绍。

有趣的链接

  • Free Word Cloud Generator:构建你的词云,按相关性和频率对结果进行排序,探索更高级的文本分析工具。
  • JS NICE:一个 JavaScript 反混淆的在线工具。
  • Slashy:一个可以创建 Notion 自定义命令的增强工具,非常不错。
  • Type Scale:一个可视化的字体大小调节工具,可以预览 CSS 字体在不同 rem 上表现。
  • Compose AI:一个帮助写作的 AI 工具,目前仅适用于英语,缺点就是太贵。
  • CSS Scan:一个可直接在网页内获取任意元素 CSS 样式的工具。

周刊第13期:一些图像 AI 模型、冒名顶替综合症

本周轮子

本周我们来实现一个被广泛使用的工具,那就是鼎鼎大名的 husky,几乎所有现代前端项目、以及 Node.js 项目都会接入这个工具,它的用途主要是统一管理项目中的 Git Hooks 脚本,不熟悉该工具的同学也不要紧,下面我们先来简单介绍一下 husky,它到底解决了什么问题,我们为什么需要使用 husky。

本周轮子:《每周轮子之 husky:统一规范团队 Git Hooks》

本周见闻

AI 画画

上周和菜头在公众号分享了一篇文章《新玩具,新瘾头》,里面介绍了一个谷歌的 AI 图像生成程序 Disco Diffusion,它可以根据描述场景的关键词渲染出对应的图像,真的非常惊艳,有兴趣可以玩一下。

从文中借一张图来展示下效果:

图片

文本生成图片

这是谷歌的一个 AI 模型 Imagen,可以根据输入文字生成写实的图片,下面这个弹吉他的猫就是它生成的:

img

Edge 的 AI 图像增强功能

微软图灵团队发布了一个 AI 模型 Super-Resolution (T-ISR),它可以提高图片的质量,它将应用在 Bing 地图以及 Edge 浏览器中,目前已经在 Edge Canary 中发布,将会在未来几个月推广给用户使用。

附上对比效果:

image-20220531233321460

冒名顶替综合症

冒名顶替综合症是一种心理现象,即一个人怀疑自己的技能、才能或成就,并有一种持续的内在恐惧,害怕被揭穿自己是个骗子。

老实讲,从我开始写博客一直到今天,期间也写过一些较有深度的技术文章,往往这个时候都能感觉到自己知识的匮乏,而当我将这些文章发布到技术论坛时,一方面我自然希望可以帮助到更多人,另一方面也希望可以通过这些文章结识到更多志同道合的朋友,可是每当有人称赞我的文章写得不错的时候,我在开心之余,也隐约担心自己是否承受得起,其实这些文章是我花费了数天晚上才勉强肝出来的,并不轻松,我深知自己并没有他们口中说的那么厉害,甚至很多时候我会忘记自己在文章中写过的知识,但是好处是我可以非常快地重新拾起来,所以为了让自己可以持续地输出对别人有帮助的文章,我需要花费更多的时间在写作这件事情上,其实收益最大的是自己。

一些 tips

没有 Docker Desktop 的情况下运行 Docker

Docker 几乎是每一位开发者都必备的工具,然而 Docker Desktop for Mac 也被无数人吐槽过,现在我们终于可以摆脱 Docker Desktop,使用 Colima 即可在你的电脑上运行 Docker。

不过笔者按照文档上的步骤,遇到了这个报错:

FATA[0000] error starting vm: error at ‘starting’: exit status 1

如果你也遇到同样的问题,可以试试这个操作步骤:

1
2
3
4
5
6
7
> brew unlink colima # 已经安装的话,先卸载

> brew install --HEAD colima

> colima start --runtime docker

> docker ps # 成功

分享文章

主要版本号不是神圣的

本文的作者是 语义化版本控制规范 的提出者,相信大家对这个规范都不陌生,它规定了版本格式应该为:主版本号.次版本号.修订号。

版本号递增规则如下:

  1. 主版本号:当你做了不兼容的 API 修改,
  2. 次版本号:当你做了向下兼容的功能性新增,
  3. 修订号:当你做了向下兼容的问题修正。

但作者在提出这个规范十年后的今天,发现了一个问题:大家把主版本号的升级看得太重要。

文章主要传递一个观点:主版本号的升级不应该作为一种营销的噱头,只要有 breaking change(重大改变) 时就应该升级,没有例外。

下面聊些不太成熟的观点,在项目的实践中,特别是开源前端组件库,经常会面临一个选择困境:我们不得不对某个 API 做出 breaking change,然而这个 API 仅在少数场景下被使用到,如果严格遵循语义化版本,那我们将要升级主版本号,但是对于用户而言,会认为是整个组件库得到了升级,所以我认为这一点上还是需要根据实际情况做考虑。

有趣的链接

  • Acapela:一个收件箱搞定所有工作通知。
  • uiverse.io:一些开源的 UI 元素,可以直接复制代码。
  • Bionic Reading:这是一种英文阅读方式,它认为将每个单词的首字母变成大写后会使阅读效率提高,Reeder 也使用了这个服务。
  • 心理学工具:一个经过学术验证的心理评估工具,如果这段时间感到焦虑、压力大的话可以简单做下测试。
  • Web Browser Engineering:一本教你使用 1000 行的 Python 代码构建一个基本但完整的Web浏览器的书。

每周轮子之 husky:统一规范团队 Git Hooks

需求

本文是每周轮子计划的第二篇,本周我们来实现一个被广泛使用的工具,那就是鼎鼎大名的 husky,几乎所有现代前端项目、以及 Node.js 项目都会接入这个工具,它的用途主要是统一管理项目中的 Git Hooks 脚本,不熟悉该工具的同学也不要紧,下面我们先来简单介绍一下 husky,它到底解决了什么问题,我们为什么需要使用 husky。

大部分公司都会采用 Git 来对项目进行代码的版本控制,其好处相信大家都知道,这里就不再赘述,通常为了保证项目的代码质量、以及更好地进行团队之间的协作,我们都会在提交代码时做一些额外的工作,包括:检查 commit message 的规范性、统一代码风格、进行单元测试等等。

而这些工作自然不能完全依靠项目成员的自觉性,毕竟人都会犯错,所以这些工作都得交给自动化工具来处理。

因此,大部分版本控制系统都会提供一个叫做钩子(Hooks)的东西,Git 自然也不例外,Hooks 可以让我们在特定的重要动作发生时触发自定义脚本,通常分为客户端和服务端,而我们接触的大部分 Hooks 都是客户端的,也就是在我们本机上执行的。

下面我们简单介绍一下如何在 Git 中使用 Hooks,我们只需要在项目的 .git/hooks 目录中创建一个与某个 hook 同名的可执行脚本即可,比如我们想要阻止一切提交,并将 commit message 打印到终端:

1
2
3
4
5
6
7
8
9
10
11
12
13
# .git/hooks/commit-msg

#!/usr/bin/env bash

INPUT_FILE=$1

START_LINE=$(head -n1 $INPUT_FILE)

echo "当前提交信息为:$START_LINE"

echo "阻止此次提交!!!"

exit 1

这里先别纠结这段脚本代码是如何做到的,只需清楚我们可以利用 Git Hooks 做到这一点,实际上我们可以在这里做任何操作,比如检查 commit message 的规范性。

讲完如何使用 Git Hooks,那我们就得讲讲这种方式存在哪些不足。

Git 文档中对客户端钩子有这么一段话:

需要注意的是,克隆某个版本库时,它的客户端钩子并不随同复制。 如果需要靠这些脚本来强制维持某种策略,建议你在服务器端实现这一功能。

简单来说就是,我们上面添加的这个 commit-msg Hook,只能在我们自己的机器上,不能被加入到版本控制中推送到远端,也就意味着我们无法同步这些 Hooks 脚本。

而 husky 主要就是为了解决这个问题,除此之外还提供了更加简便的方式来使用 Git Hooks,而无须采用上面那种方式。

先来介绍一下大家所熟知的 husky 用法,首先要安装这个工具,可以使用全局安装,但一般更推荐在项目本地安装:

1
npm install husky -D

然后我们在 package.json 中添加以下代码:

1
2
3
4
5
6
7
8
{
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "yarn test"
}
}
}

以上这种方式是 husky v4 版本之前的配置方式,相信大部分同学都对这种方式很熟悉了,而在最新版本 husky(v7 之后) 已经不支持这样使用,而是采用命令行配置的方式:

1
2
npx husky add .husky/pre-commit "lint-staged"
npx husky add .husky/pre-push "yarn test"

它的配置方式之所以会有如此翻天覆地的变化是有原因的,不过说来话长,我们下面会讲到,这里先按下不表。

本文就带领大家从 0 到 1 造一个 husky,我们先从第一种使用方式开始,然后一步步来看为什么 husky 在新版本会选择改变它的配置方式。

让我们开始吧!

实践

v4 以前的版本

我们先来以最 low 的方式实现它,第一步是对 package.json 进行配置,以便测试:

1
2
3
4
5
6
7
{
"husky": {
"hooks": {
"pre-commit": "echo hello husky!"
}
}
}

然后读取 package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// husky.js
const pkg = require('./package.json')

function husky() {
if (!pkg?.husky?.hooks) {
return
}

if (typeof pkg.husky.hooks !== 'object') {
return
}

const hooks = pkg.husky.hooks

console.log(hooks)
}

husky()

现在我们现在已经能拿到 hooks 相关的配置,然后我们把相关的脚本内容写入到对应的 hooks 可执行文件:

1
2
3
4
5
for (const [name, value] of Object.entries(hooks)) {
const script = `#!/bin/sh\n${value}\n`

fs.writeFileSync(`./.git/hooks/${name}`, script, { mode: '751' })
}

执行一下:

1
node husky.js

然后我们就可以看到 .git/hooks 下面多了一个 pre-commit 可执行文件:

1
2
3
4
> cat pre-commit

#!/bin/sh
echo hello husky!

这时候进行 commit 也可以看到输出:

1
2
3
> git commit -m "test"

hello husky!

这时候我们已经完成了 husky 大部分的功能,但是这里还存在这么一个问题:如果现在我去修改 package.json 中的 husky 配置,hooks 文件如何同步更新?

举个例子,如果现在把 package.json 改成这样:

1
2
3
4
5
6
7
8
{
"husky": {
"hooks": {
- "pre-commit": "echo hello husky!"
+ "pre-commit": "echo hello husky2!"
}
}
}

然后进行 commit,它输出的仍然是 hello husky!,实际上如果我们不是手动执行写入 hooks 文件这个操作,甚至连第一步都做不到,可是回想上面 husky 的使用方式,我们只需要安装 husky 后进行配置即可,并不需要手动执行什么命令。

那 Huksy 是如何做到这一点的呢?动动你聪明的小脑瓜,有没有解决方案呢?

我们先来分析一下为什么无法做到自动同步更新 hooks,归根到底就是因为无法检测修改 package.json 后自动执行写入 hooks 操作,那我们不妨换一种思路:不用在修改 package.json 时执行写入操作,而是在执行 hooks 时去执行 package.json 中对应的 hooks

可能有点拗口,换句话说就是我们在一开始就把所有的 hooks 预注册了,然后在每一个 hooks 脚本中做同一件事:寻找 package.json 中对应的 hooks 并执行。

可能会觉得有点奇技淫巧 ,但也不失为一种曲线救国的方式,而事实上在 husky v4 之前还真的是这么做的。

那我们如何在一开始就注册所有 hooks 呢?

翻了一下 npm 的文档,发现有一个 install 钩子,它会在 npm install 后执行。

首先我们的项目结构如下:

1
2
3
4
5
6
.
├── husky-test
│   ├── husky
│   │   ├── husky.js
│   │   └── package.json
│   └── package.json

husky/package.json 添加以下代码:

1
2
3
4
5
6
7
8
{
"name": "husky",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"install": "node husky"
}
}

然后在 husky-test 安装这个 npm 包:

1
2
3
4
5
6
7
8
{
"name": "husky-test",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"husky": "./husky"
}
}

这时候执行 npm install 会运行 husky/husky.js ,我们就可以在这个文件中预注册所有的 hooks,不过在此之前我们先梳理一下整体实现逻辑:

  1. 我们要在 husky.js 中预注册所有的 hooks,可以在这个文档中参考所有的 hooks。
  2. 我们要在所有的 hooks 中写入脚本内容,使其可以在被执行时寻找 package.json 中对应的 hook,并将其执行结果返回。
    1. 因为 hooks 的 exit code 非 0 时要中断本次操作。

因此,经过梳理后,我们的目录结构调整如下:

1
2
3
4
5
6
7
8
9
10
11
.
├── husky // husky 包
│   ├── package.json
│   ├── husky.js // install 入口
│   ├── installer // 初始化,预注册 hooks
│   │   └── index.js
│   ├── runner // 寻找对应的 hook 并执行
│   │   └── index.js
│   └── sh // 所有 hooks 统一调用脚本
│   └── husky.sh
└── package.json // 测试

我们在 husky.js 中调用 install 进行初始化操作:

1
2
3
const install = require('./installer')

install()

然后在 installer/index.js 中预注册 hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// installer/index.js
const fs = require('fs')
const cp = require('child_process')
const path = require('path')

const hookList = [
'applypatch-msg',
'pre-applypatch',
'post-applypatch',
'pre-commit',
'pre-merge-commit',
'prepare-commit-msg',
'commit-msg',
'post-commit',
'pre-rebase',
'post-checkout',
'post-merge',
'pre-push',
'post-update',
'push-to-checkout',
'pre-auto-gc',
'post-rewrite',
'sendemail-validate'
]

function git(args, cwd = process.cwd()) {
return cp.spawnSync('git', args, { stdio: 'pipe', encoding: 'utf-8', cwd })
}

function getGitRoot() {
return git(['rev-parse', '--show-toplevel']).stdout.trim()
}

function getGitHooksDir() {
const root = getGitRoot()

return path.join(root, '.git/hooks')
}

function getHookScript() {
return `#!/bin/sh

. "$(dirname "$0")/husky.sh"
`
}

function writeHook(filename, script) {
fs.writeFileSync(filename, script, 'utf-8')
fs.chmodSync(filename, 0o0755)
}

function createHook(filename) {
const hookScript = getHookScript()

writeHook(filename, hookScript)
}

function createHooks(gitHooksDir) {
getHooks(gitHooksDir).forEach(createHook)
}

function getHooks(gitHooksDir) {
return hookList.map((hookName) => path.join(gitHooksDir, hookName))
}

function getMainScript() {
const mainScript = fs.readFileSync(
path.join(__dirname, '../../sh/husky.sh'),
'utf-8'
)

return mainScript
}

function createMainScript(gitHooksDir) {
fs.writeFileSync(path.join(gitHooksDir, 'husky.sh'), getMainScript(), 'utf-8')
}

export default function install() {
const gitHooksDir = getGitHooksDir()

createHooks(gitHooksDir)
createMainScript(gitHooksDir)
}

做完这一步的结果是在安装 husky 时,会自动创建 hooks、并将 husky.sh 复制到 .git/hooks 中,所有 hooks 都会调用 husky,sh

1
2
3
#!/bin/sh

. "$(dirname "$0")/husky.sh"

husky.sh 中主要是做一件事,调用 runner/index

1
2
3
4
5
# sh/husky.sh
gitParams="$*"
hookName="$(basename "$0")"

npm husky-run $hookName "$gitParams"

husky-run 是我们自定义的一个命令,需要在 package.json 中先注册:

1
2
3
4
5
6
7
8
9
10
11
12
// husky/package.json
{
"name": "husky",
"version": "1.0.0",
"license": "MIT",
"bin": {
+ "husky-run": "./runner/index.js"
},
"scripts": {
"install": "node husky"
}
}

所以实际上就是调用 runner/index,我们要在这个文件中寻找对应的 hook 并执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#!/usr/bin/env node

const { spawnSync } = require('child_process')
const { cosmiconfigSync } = require('cosmiconfig')

function getConf(dir) {
const explorer = cosmiconfigSync('husky')
const { config = {} } = explorer.search(dir) || {}

const defaults = {
skipCI: true
}

return { ...defaults, ...config }
}

function getCommand(cwd, hookName) {
const config = getConf(cwd)

return config && config.hooks && config.hooks[hookName]
}

function runner(
[, , hookName = '', husky_GIT_PARAMS],
{ cwd = process.cwd() } = {}
) {
const command = getCommand(cwd, hookName)

const env = {}

if (husky_GIT_PARAMS) {
env.husky_GIT_PARAMS = husky_GIT_PARAMS
}

if (command) {
return runCommand(cwd, hookName, command, env)
}

return 0
}

function runCommand(cwd, hookName, cmd, env) {
const { status } = spawnSync('sh', ['-c', cmd], {
cwd,
env: { ...process.env, ...env },
stdio: 'inherit'
})

if (status !== 0) {
const noVerifyMessage = [
'commit-msg',
'pre-commit',
'pre-rebase',
'pre-push'
].includes(hookName)
? '(add --no-verify to bypass)'
: '(cannot be bypassed with --no-verify due to Git specs)'

console.log(`husky > ${hookName} hook failed ${noVerifyMessage}`)
}

if (status === 127) {
return 1
}

return status || 0
}

async function run() {
try {
const status = await runner(process.argv)
process.exit(status)
} catch (err) {
console.log('husky > unexpected error', err)
process.exit(1)
}
}

run()

我们来测试一下,在 package.json 添加如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "husky-test",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"husky": "./husky"
},
+ "husky": {
+ "hooks": {
+ "pre-commit": "echo 123 && exit 1"
+ }
}
}

然后进行一次 commit,得到结果:

1
2
3
4
> git commit -m "test"

123
husky > pre-commit hook failed (add --no-verify to bypass)

OK,到这里我们除了一些代码上的健壮性问题以外,已经把大部分 husky v4 版本的核心功能都给实现了。

不过我们已经能够对 husky 的实现方式给摸透,各位同学认为这样的实现方式好不好呢?

其实 husky 的这种通过预注册所有 hooks 的方式一直被人诟病,详见 #260

其实 husky 的维护者也知道这种方式属实不妥,不过由于当时 Git 的 hooks 机制,只能一直顶着骂名维护下去。

终于,在 Git v2.9 的版本升级中,正式支持通过配置 core.hooksPath 自定义项目的 hooks 的存放路径,也即意味着可将 hooks 加入版本控制,于是 husky 二话不说地进行了重构,用了一种全新的实现方式来做这件事,也就是我们今天看到的 husky v5 以后的版本(截止目前最新的 v8 版本)。

同时 husky 因为配置方式的缘故,使其仅局限于 node.js 项目,为了提高 husky 的使用范围,最新版本决定采用 CLI 配置的方式,参见:Why husky has dropped conventional JS config

因此,最新版本的 husky 实现代码与前面版本截然不同,下面我们继续从 0 到 1 开始实现 husky。

v4 以后的版本

我们再把最新版本的 husky 使用方式介绍一遍:

1
2
3
4
5
6
7
# 初始化
npm set-script prepare "husky install"
npm run prepare

# 配置 hook
npx husky add .husky/pre-commit "npm test"
git add .husky/pre-commit

可以发现最新版本的 husky 无需利用 npm 的 install 钩子,毕竟已经不再局限于 node.js 项目,所以初始化操作需要另寻僻径,在 husky 文档中给出的解决方案是利用 npm 的 prepare 钩子,其可以执行 npm publish 和 不带参数的 npm install 时执行。

同时 husky 一共支持以下命令:

  1. husky install:安装,主要是配置 Git 的 core.hooksPath
  2. husky uninstall:卸载,主要是恢复对 Git 的 core.hooksPath 的修改
  3. husky set:新增 hook
  4. husky add:给已有的 hook 追加命令

因此,它的实现方式并不难,这里我直接张贴核心源码过来,首先是 CLI 的入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Get CLI arguments
const [, , cmd, ...args] = process.argv
const ln = args.length
const [x, y] = args

// Set or add command in hook
const hook = (fn: (a1: string, a2: string) => void) => (): void =>
// Show usage if no arguments are provided or more than 2
!ln || ln > 2 ? help(2) : fn(x, y)

// CLI commands
const cmds: { [key: string]: () => void } = {
install: (): void => (ln > 1 ? help(2) : h.install(x)),
uninstall: h.uninstall,
set: hook(h.set),
add: hook(h.add),
['-v']: () =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-var-requires
console.log(require(p.join(__dirname, '../package.json')).version),
}

// Run CLI
try {
// Run command or show usage for unknown command
cmds[cmd] ? cmds[cmd]() : help(0)
} catch (e) {
console.error(e instanceof Error ? `husky - ${e.message}` : e)
process.exit(1)
}

install 安装初始化

安装初始化主要做以下事情:

  1. 拷贝 husky.sh 到项目中,其主要工作是添加 debug 开关以及支持 .huskyrc 配置文件,最后将其添加到 .gitignore
  2. 将 Git 的 core.hooksPath 修改为项目目录的 .husky,后续添加的 hooks 都将存放在此目录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// src/index.ts

export function install(dir = '.husky'): void {
if (process.env.HUSKY === '0') {
l('HUSKY env variable is set to 0, skipping install')
return
}

// Ensure that we're inside a git repository
// If git command is not found, status is null and we should return.
// That's why status value needs to be checked explicitly.
if (git(['rev-parse']).status !== 0) {
return
}

// Custom dir help
const url = 'https://typicode.github.io/husky/#/?id=custom-directory'

// Ensure that we're not trying to install outside of cwd
if (!p.resolve(process.cwd(), dir).startsWith(process.cwd())) {
throw new Error(`.. not allowed (see ${url})`)
}

// Ensure that cwd is git top level
if (!fs.existsSync('.git')) {
throw new Error(`.git can't be found (see ${url})`)
}

try {
// Create .husky/_
fs.mkdirSync(p.join(dir, '_'), { recursive: true })

// Create .husky/_/.gitignore
fs.writeFileSync(p.join(dir, '_/.gitignore'), '*')

// Copy husky.sh to .husky/_/husky.sh
fs.copyFileSync(p.join(__dirname, '../husky.sh'), p.join(dir, '_/husky.sh'))

// Configure repo
const { error } = git(['config', 'core.hooksPath', dir])
if (error) {
throw error
}
} catch (e) {
l('Git hooks failed to install')
throw e
}

l('Git hooks installed')
}

uninstall 卸载

卸载的工作就更简单了,只需要恢复对 Git 的 core.hooksPath 的配置即可:

1
2
3
export function uninstall(): void {
git(['config', '--unset', 'core.hooksPath'])
}

set 添加 hook

添加 hook 只需要创建对应的 hook 脚本文件,并写入内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function set(file: string, cmd: string): void {
const dir = p.dirname(file)
if (!fs.existsSync(dir)) {
throw new Error(
`can't create hook, ${dir} directory doesn't exist (try running husky install)`,
)
}

fs.writeFileSync(
file,
`#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
${cmd}
`,
{ mode: 0o0755 },
)

l(`created ${file}`)
}

add 追加 hook 命令

add 命令是对已有的 hook 文件追加脚本文件,

1
2
3
4
5
6
7
8
export function add(file: string, cmd: string): void {
if (fs.existsSync(file)) {
fs.appendFileSync(file, `${cmd}\n`)
l(`updated ${file}`)
} else {
set(file, cmd)
}
}

结语

本文带领大家从 0 到 1 实现了 v4 以及最新版本的 husky,相信大家看完后对 husky 的实现方式也有了一定的了解,在以后的工作中使用它将会更加地得心应手,但如果你所在的项目中不是使用 Git,而是其它版本控制工具,也可以尝试基于 husky 改造,比如本人就曾尝试将 husky 改造使其支持 Mercurial

周刊第12期:每周轮子计划、程序员应该如何写博客

本周轮子

以前看过一个小册子 《一天学习一个 npm 轮子,十天后变成轮子哥 》,觉得这种「首先实现一个最 Low 的解决方案,然后慢慢优化,进而推导出最终的源码」的学习思路非常不错,所以我也决定从现在开始,每周都学习造一个 npm 轮子。

当然前期只要专注于那些日常使用、相对简单的 npm 包,毕竟能力有限,太复杂的包就很难可以讲透。

这是本周的轮子:《每周轮子之 only-allow:统一规范团队包管理器》

本周见闻

程序员应该怎么写博客?

这是在 V2EX 上看到的一个帖子,OP 是之前周刊有提到过的 胡涂说 的博主,在这个帖子中,我收获不少有用的建议,也意外地发现到几个不错的博客。我也想借此机会聊一聊关于这个话题的一些想法,也算是当作我个人写作的总结与反思。

回顾本站的第一篇文章《锐捷无线AP学习笔记 - 入门篇》,写于 2018 年,到现在也就第 4 个年头,相比有些人动不动建站十几年,自然算得上年轻,但我认为我在写博客这件事上有过不少的挣扎与坚持,总结起来一共经历了以下几个阶段,但其实以下阶段并不是层层递进式的,它们有可能会同时进行:

  1. 记录学习笔记,这个阶段的产出主要是一些较为基础的学习笔记,原创度也较低,属于网上一搜一大把那种,对他人的帮助接近于无,还记得当时把一篇文章分享到技术社区上,被某些暴躁的老哥说我误人子弟,更有甚者直接开怼让我干脆把整本书都抄过来,当时有点气馁,但也承认自己的确是很菜,也曾自我怀疑过是否应该继续往中文社区「倒垃圾」,但最后还是坚持下来了。据我了解很多程序员刚开始写博客都是写这类学习笔记,也有很多人仅仅停留在这个阶段,甚至放弃,实在是太可惜了。
  2. 资源分享类,写技术文章很难写,有深度的技术文更是难上加上,所以曾有一段时间我特别喜欢写「资源分享」类的文章,譬如《分享一些好用的网站》《分享一些好用的 Chrome 扩展》,这类文章写起来压根不费多少时间和经历,你只需要把平时经常使用的网站、工具分享一一罗列出来,,就能在社区上收获不少的点赞收藏,这也是为什么 GitHub 上简体中文项目的 Markdown 项目如此之多的原因,但是我后来意识到,写这类文章对我个人能力的提升并没有多大帮助,如果你知道一些非常有用的工具,你确实应该将它分享出去给更多人,这是非常有价值的,但我的建议是千万不要满足于只写这类文章。
  3. 较有深度的技术文章,随着工作时间的增长,在技术上有了一些的积累,我开始尝试写一些较有深度的技术文章,包括一些经典面试题讲解、源码阅读、某个知识点深入剖析、一些工具类库的踩坑记录等,写这类文章是最痛苦的,因为你会发现在写的过程突然在某个地方你自己也无法讲清楚,说明你并没有理解透彻,于是只能逼迫自己一边查阅文档、一边根据自己的理解用自己的话把它讲透,这过程中的收获自然也是非常大,对其他人的帮助也不小。
  4. 与技术无关的,人都是有感情的动物,除了技术以来,总需要聊点其他的东西,有可能是生活上的一些感悟,也有可能是你针对某个事情的看法,这类文章,它既有可能会引起读者的共鸣,也有可能会因为与读者的看法不同而遭到反感,且很多时候,自己也不确定这些看法到底是不是正确的,所以会害怕被别人看到自己不成熟的一面,我也曾有过这方面的顾虑,不敢在博客上公开谈论太多与技术无关的事情,但后来发现是我多虑了,因为这类文章,往往最终的读者只有你自己,因为读者看你的文章,他也只会关心对他有帮助的地方,而且即便你当时所记录下来的自己可能是不成熟的,这也是你成长的印记,比如我常常会在迷茫的时候翻看起之前刚踏入职场时写下的《我为什么会成为一名程序员》,告诉自己不要忘了为什么会走上技术这条路,所以不要害怕写技术以外的东西,这些稚嫩的文字很有可能会在多年以后一直激励着你。

可能还有些同学会认为自己文笔不好、词穷,写不了这么多文字,其实也是多虑了,我坦白我的文笔其实很差,不能像别人那样出口成章、行云流水,但其实你只要把意思表达清楚、并且注入你的感情即可,总之多写、坚持写。

一些 tips

QOTD 协议

QOTD 的全程是 Quote of the Day,翻译过来就是「每日报价」,在 RFC 865 中定义,监听的端口是 17,这是一个非常少用到的协议,目前仅剩的公共 QOTD 服务器只有几个:

服务器地址TCP 端口UDP 端口
djxmmx.net1717
alpha.mike-r.com1717
cygnus-x.net1717

可以用它做什么呢?

比如这里就有一个在 GitHub Action 上利用该协议定时获取 djxmmx.net 服务器上的名人名言,将它更新到 Github Profile 中。

分享文章

Bash Pitfalls: 编程易犯的错误

本文是《Bash Pitfalls》的中文翻译版,介绍了40多条日常 Bash 编程中,老手和新手都容易忽略的错误编程习惯。作者会在每条给出错误的范例上,详细分析与解释错误的原因,同时给出正确的改写建议。

Docker 镜像构建的一些技巧

本文分享几个 Docker 镜像构建的一些技巧,可以帮助你提高 Docker 镜像构建的效率,对于老手来说已经是非常基本的事情了,但是对于新手还是很有帮助的。

原文一共举例了 4 个技巧,在此我只详细讲解第一个技巧,其余的麻烦移步原文查看。

  1. 删除缓存

使用 apt、pip 等包管理器下载包时一般都会产生缓存,以便后续下载时使用,但是在 Docker Image 中,我们不需要这些缓存,所以一般都会在下载后,手动清除缓存:

1
2
3
4
RUN dnf install -y --setopt=tsflags=nodocs \
httpd vim && \
systemctl enable httpd && \
dnf clean all

要切记千万不要像这样分开写,因为 Dockerfile 里面的每一个 RUN 都会创建一层新的 layer,这样其实是创建了 3 层 layer,前 2 层带来了缓存,第三层删除了缓存:

1
2
3
4
FROM fedora
RUN dnf install -y mariadb
RUN dnf install -y wordpress
RUN dnf clean all

但其实 Docker 在 v1.13 中引入了 –squash 参数,可以在完成构建后将所有的 layers 压缩成一个 layer,也就是说,最终构建出来的 Docker image 只有一层,所以,如上在多个 RUN 中写 clean 命令,其实也可以。

1
docker build --squash
  1. 改动不频繁的内容往前放
  2. 构建和运行 Image 分离
  3. 检查构建产物

JavaScript 函数式组合:有什么大不了的?

这是我看到过所有写 JavaScript 函数式组合里面最通俗易懂的一篇文章,作者从头开始一步步地实现 compose、pipe、flow 等方法,并且让对函数式组合了解不多的同学知道,函数式组合的好处在哪?

举个例子,假如我们使用 Array 的方法是这样写的:

1
2
3
4
5
6
const comments = commentStrs
.filter(noNazi)
.slice(0, 10)
.map(emphasize)
.map(itemize)
.join('\n');

而改用函数式组合的方式,则是这样:

1
2
3
4
5
6
7
const comments = pipe(commentStrs,
filter(noNazi),
take(10),
map(emphasize),
map(itemize),
join('\n'),
);

这样写的好处在哪呢?

首先,我们可以增加任何 Array 原型上没有的自定义方法:

1
2
3
4
5
6
7
8
const comments = pipe(commentStrs,
filter(noNazi),
take(10),
map(emphasize),
map(itemize),
join('\n'),
+ chaoticListify,
);

另外,我们可以自由地实现像 map 这些方法,比如用生成器的方式改成它,而无需改变调用它们的方式:

1
2
3
4
5
const map = f => function*(iterable) {
for (let x of iterable) yield f(x);
};

const join = s => iterable => [...iterable].join(s);

综上所述,使用函数式组合的方式编写代码可以让我们写出更加简洁、优雅的代码,更重要的是它给我们提供了另一种思考的方式。

中国黑客关系图

本文是新书《沸腾信安志》的一篇预热文章,主要讲述了中国上个世纪末到本世纪初的传奇黑客们的故事,想要了解有哪些著名的黑客,以及他们今何在的同学可以看看。

网络安全行业和武侠江湖是很像的,有门派组织,有江湖名号,有武林大会,有绝计和宝物,而且都是大侠少而恶盗多,甚至连朝廷的管制方式都很相似。

这种氛围里,竟然出了这样一群奇人。

他们在最艰苦的岁月里,只要把道德底线稍微降低一点,就可以衣食无忧,然而他们没有;

他们掌握着最高超的技术,却拿着流量行业一半甚至更低的薪水,只要稍微做点灰产,就能摆脱困境,然而他们没有;

他们忍受着社会的质疑,承担着行业流氓带来的负面,却仍然坚持着自己热爱的技术创新。

直到现在,他们终于等到了自己的时代。

有趣的链接

  • Similarweb:查看并分析任何网站流量,站长必备工具
  • Codeit:手机连接 Git 查看代码的神器 APP
  • Queue:使用 Notion 发布 Twitter 的工具

每周轮子之 only-allow:统一规范团队包管理器

需求

首先我们来提一个团队开发中很常见的需求:一般来说每个团队都会统一规定项目内只使用同一个包管理器,譬如 npm、yarn、pnpm 等,如果成员使用了不同的包管理器,则可能会因为 lock file 失效而导致项目无法正常运行,虽然这种情况一般都可以通过项目的上手文档来形容共识,但有没有更好的解决方案,比如在项目安装依赖时检测如果使用了不同的包管理器就抛出错误信息?

当然是可以的,pnpm 就有一个包叫做 only-allow ,连 vite 都在使用它,所以本周我们就从 0 到 1 实现这个工具,以此对它的工作原理一探究竟。

实践

说干就干,我们先在 npm 文档 搜寻一番,发现有一个钩子叫做 preinstall

可以在运行 npm instal 之前执行某个命令,当 exit code 非 0 时终止运行

所以第一步是在 package.json 中添加以下代码:

1
2
3
"scripts": {
"preinstall": "node check-npm.js"
}

接下来的问题就是:我们如何知道用户使用了哪一个包管理器?

我们知道 process.env 会包含当前脚本的运行环境,首先我们将它打印看看

分别使用 yarnnpm install 后,发现了以下几个相关字段的区别:

使用 yarn 安装:

1
2
3
4
5
{
npm_config_registry: 'https://registry.yarnpkg.com',
npm_execpath: '/usr/local/lib/node_modules/yarn/bin/yarn.js',
npm_config_user_agent: 'yarn/1.22.11 npm/? node/v16.13.2 darwin arm64',
}

使用 npm 安装:

1
2
3
4
5
{
npm_config_metrics_registry: 'https://registry.npmjs.org/',
npm_execpath: '/opt/homebrew/lib/node_modules/npm/bin/npm-cli.js',
npm_config_user_agent: 'npm/8.5.5 node/v16.13.2 darwin arm64 workspaces/false',
}

以下是三者的解释:

  1. npm_config_metrics_registry:npm 源,就是当我们安装 npm 包会从这个服务器上获取,可以通过 npm config set registry 或者 等工具进行配置。
  2. npm_execpath:当前 npm 包管理器的执行目录,这个路径会根据你安装的方式而不同。
  3. npm_config_user_agent:由包管理器设置的 UA,每个包管理器都不一样,比如 npm lib/utils/config/definitions.js#L2190,因此我们可以使用这个信息来判断客户端。

因此我们可以通过 process.env.npm_config_user_agent 获取当前用户使用的包管理器,那么接下来的工作很简单了。

我们先写一个最 Low 的解决方案:

1
2
3
4
5
6
7
8
9
const wantedPM = 'yarn'

const usedPM = process.env.npm_config_user_agent.split('/')[0]

if (usedPM !== wantedPM) {
console.error(`You are using ${usedPM} but wanted ${wantedPM}`)

process.exit(1)
}

至此,我们的核心功能就已经实现了,还不赶紧发到 GitHub 开源一波坐等 stars?

别急,我们来思考下这段代码存在哪些不足:

  1. 应该由用户指定可以使用哪一个包管理器。
  2. 这段代码的健壮性如何?

那我们再修改一波,首先是接收用户传递参数,指定使用的包管理器:

1
2
3
"scripts": {
"preinstall": "node check-npm.js yarn"
}

然后改为通过接收参数:

1
2
3
4
5
6
7
8
9
10
11
12
+ const argv = process.argv.slice(2)

+ const wantedPM = argv[0]
- const wantedPM = 'yarn'

const usedPM = process.env.npm_config_user_agent.split('/')[0]

if (usedPM !== wantedPM) {
console.error(`You are using ${usedPM} but wanted ${wantedPM}`)

process.exit(1)
}

还有第二个问题,这段代码的健壮性如何?譬如以下情况:

  1. 用户不传或乱传参数怎么办?
  2. 如果以后有新需求:除了要限制包管理器,还要限制到具体某个版本怎么办?

所以,我们再调整一波代码,检测传入的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const PACKAGE_MANAGER_LIST = ['npm', 'yarn', 'pnpm']

const argv = process.argv.slice(2)

if (argv.length === 0) {
const name = PACKAGE_MANAGER_LIST.join('|')

console.log(`Please specify the wanted package manager: only-allow <${name}>`)

process.exit(1)
}

const wantedPM = argv[0]

if (!PACKAGE_MANAGER_LIST.includes(wantedPM)) {
const name = PACKAGE_MANAGER_LIST.join(',')

console.log(
`"${wantedPM}" is not a valid package manager. Available package managers are: ${name}.`
)

process.exit(1)
}

然后,我们将获取 UA 的代码抽离出来,并使其可以获取版本,以便后续扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getPackageManagerByUserAgent(userAgent) {
if (!userAgent) {
throw new Error(`'userAgent' arguments required`)
}

const spec = userAgent.split(' ')[0]
const [name, version] = spec.split('/')

return {
name,
version
}
}

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const PACKAGE_MANAGER_LIST = ['npm', 'yarn', 'pnpm']

const argv = process.argv.slice(2)

if (argv.length === 0) {
const name = PACKAGE_MANAGER_LIST.join('|')

console.log(`Please specify the wanted package manager: only-allow <${name}>`)

process.exit(1)
}

const wantedPM = argv[0]

if (!PACKAGE_MANAGER_LIST.includes(wantedPM)) {
const name = PACKAGE_MANAGER_LIST.join(',')

console.log(
`"${wantedPM}" is not a valid package manager. Available package managers are: ${name}.`
)

process.exit(1)
}

const usedPM = getPackageManagerByUserAgent(
process.env.npm_config_user_agent
).name

if (usedPM !== wantedPM) {
console.error(`You are using ${usedPM} but wanted ${wantedPM}`)

process.exit(1)
}

function getPackageManagerByUserAgent(userAgent) {
if (!userAgent) {
throw new Error(`'userAgent' arguments required`)
}

const spec = userAgent.split(' ')[0]

const [name, version] = spec.split('/')

return {
name,
version
}
}

结语

很好,现在我们已经将这个 npm 包的功能给实现了:only-allow,可以看下它的源码:bin.js

不过发现了一个问题:上面提到过 preinstall 钩子会在安装依赖时触发,但是经验证,npm 和 yarn 调用 preinstall 的时机不一样,npm 仅会在 npm install 时运行,而 npm install <pkg-name> 则不会,但 yarn 则会在 yarnyarn add <pkg-name> 时都运行,所以如果想用这种方式限制 npm 使用者,可能无法达到预期,该问题在 2021 年就有人提出,但目前仍未有解决方案出现。

周刊第11期:扩大你的运气表面积(Luck Surface Area)

扩大你的运气表面积

想说两件事,第一件事,在上周的某天下午,我收到了一封邮件:

image-20220428231741858

这是 V2EX 上的一位老哥发来的邮件,他也在写周刊《野生架构师》,估计不少人都听过,毕竟最近在 V2EX 上比较活跃,收到这封来信让我非常开心,毕竟自己的周刊内容得到了他人的认可。

同时也让我知道了一个新的概念:运气表面积(Luck Surface Area)。

这个概念该如何理解,根据《How to Increase Your Luck Surface Area》的描述:

在你的生活中发生意外的数量,就是你的运气表面积,它与你所热爱的事情、以及被有效沟通的人数是成正比的,换句话说,你的运气是你自己创造的。

它的公式是:运气表面积 = 你采取的行动 [X] x 你沟通的人数 [X]

运气表面积的工作原理是这样的,当你投入精力去做某件事情,你分享出去,你就会产生价值,但很多时候,这个价值会被所影响的人放大,它有可能会以某种你未曾预料的方式回报你,比如招聘你、投资你,但无论以何种方式发生,它都是偶然的。

说回上面那封邮件,我之所以写周刊在《我为什么要写周刊》 中有提到,我希望我能够对我每周所阅读的文章做一份总结,以此巩固我自身的理解,并希望不时输出自己观点的同时也能够帮助到其他人。

正因为我写周刊,被这位老哥看到了,发来这么一封邮件,让我感受到了被认可的快乐,因此更加坚定了我继续写周刊的信心,这就是我的运气表面积在扩大,但这一切都是非常偶然的,我在做这件事情的时候,我没有预料到这一点,所以正是因为我做这件事情为我自己创造了运气。

另外一件让我感到运气表面积在扩大的事,在一直订阅的胡涂说的周刊《No.13: 周刊的周刊》发现了他推荐我的周刊,而他的原话是:

看到很多博主在写周刊,我想还是把这一传统给拾起来。

这让我想到了一件非常奇妙的事情,前段时间 V2EX 上有讨论 为什么忽然间大家都开始做周刊了,但我坦承我不是在跟风,或许别人也是如此,那么有没有可能是因为某一个人持续地在做这件事,影响了很多人,然后大家又互相影响,无形中扩大了大家的运气表面积,如果真是这样,那我认为是阮一峰影响了我,而我也可能在影响其他人。

最后从这里摘录一下以下三者的不同:

  1. 幸运是发生在你身上的事情,生在好人家是福,被雷劈是祸。
  2. 机会需要您采取行动。您需要抓住机会,例如购买彩票,或约某人出去约会,以从偶然事件中受益。
  3. 运气是成功或失败显然是偶然造成的。看起来这是一个机会,因为我们很少看到在成功或失败之前发生的一切。运气是通过发现和创造机会来实现的。这是你行为的直接后果。

所以,记住一件事情,你可以创造你的运气,只要你去做更多的事情影响别人

本周见闻

POSSE 和 Mastodon

POSSE 的全称是 Publish (on your) Own Site, Syndicate Elsewhere,意为「在你自己的网站上发布,在其他地方联合」。

这是 2010 年首次在《Tantek Celik Diso 20 Brass Tacks》 提出的一种联合模型方案:

在你自己的网站上发布,拥有自己的 URL 和永久链接,并通过其他社交媒体、社区中公布该链接。

可以通过这段短片《Own your content on Social Media using the IndieWeb》快速了解 POSSE。

采用该模型的好处是:如果我的 Twitter 帐户被删除(被官方或我自己删除),我不会丢失任何重要内容。

其实已经不少人采用这个方案,譬如就不少博主都会将文章链接发到 Twitter、Telegram 频道上,一方面是起到推广的作用,另一方面正是因为 POSSE。

你也可以通过这个链接《POSSE - IndieWeb》 了解更多信息。

之所以越来越多人采用这个方案,都是因为对官方的不信任,所以希望把数据掌握在自己的手中,所以这里简单介绍一下 Mastodon:

Mastodon 是互联网上最大的去中心化的社交网络,它由一个非营利组织基于开放网络标准建立。

简单来说就是一个自托管的类 Twitter、微博的网站,你可以构建并部署自己的 Mastodon 实例,也可以加入他人的实例,顺便一提之前川普搞的社交媒体的 Truth Social 也是基于 Mastodon 的。

但 Mastodon 也有它的不足之处,譬如:

  1. 稳定性,毕竟只有少部分人在维护实例。
  2. 还没有一个好的策略去防止虚假信息,仇恨言论,骚扰等。

但也有不少人开始尝试从 Twitter 转向 Mastodon,比如 2ality 的博主 Axel Rauschmayer,有兴趣的同学可以从 Explore Fosstodon 开始探索。

造谣的成本有多低?

在如今的网络,造谣的成本非常低,比如我们就可以通过 Fake Details 这个网站去伪造各式各样的假信息,可以伪造下面这些社交媒体的假截图:

  • Twitter 推文
  • Tiktok
  • Youtube
  • 等等…

甚至只需要在某个完全不相关的视频上面截个图,就可以张冠李戴地套在另外一件时事上面,而我们该如何去辨别这些假信息呢?

《周刊第4期:独立思考》 曾推荐过几个比较可靠的事实核查网站,这里再贴一下:

除了看以上这些网站给出的结论,我们还可以通过这些网站提供的核查工具箱,自己去学习如何做事实核查。

一些 tips

Chrome 用户体验报告比较工具

你只需要在 Chrome UX Report Compare Tool 这个网站上输入一个或多个 URL,它将会列出它们的各项性能指标,如下图:

image-20220505221453130

然而当我输入本 Blog 的域名时,它提示我:Chrome 用户体验报告中没有足够的数据,这是访问人数不够多的缘故。

如果想要了解背后的技术细节,可以看这篇文章《使用 Chrome 用户体验报告 API》

使用 Javascript 的可选操作符可能会破坏你的代码

熟悉 JS 的同学都知道我们可以通过 obj?.x 这个语法来更安全地访问对象属性等,但是如果盲目地使用这个语法,可能会导致一些意想不到的事情发生,比如以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function test(value) {
console.log(`${value && value.length}, ${value?.length}`);
}

test(undefined); // undefined, undefined
test(null); // null, undefined
test(true); // undefined, undefined
test(false); // false, undefined
test(1); // undefined, undefined
test(0); // 0, undefined
test({}); // undefined, undefined
test([]); // 0, 0
test({ length: "a" }); // a, a
test(''); // , 0
test(NaN); // NaN, undefined

通过这个例子我们发现,仅当值为 undefined 的时候,Optional Chaining 才有可能按照预期运行,所以我们编写代码的时候一定要多加注意,什么时候应该用 && 做短路判断,什么时候使用 Optional Chaining 语法。

在 git 仓库中使用 find . -exec sed 是很危险的

如果你想在 git 存储库中全局替换某个字符,那么你很有可能会遇到这个问题:Find and replace with sed in directory and sub directories,这是因为 find 命令没有忽略 .git 目录,运行该命令可能会导致 .git 目录损坏,所以更好的方式是使用 git ls-files:

1
git ls-files | xargs sed -i -e 's/apple/orange/g'

分享文章

凯文 · 凯利 70 岁生日写的 103 条人生忠告

2022年4月28日,凯文·凯利(Kevin Kelly)在他的网站上发布了 70 岁生日的文章:103 Bits of Advice I Wish I Had Known,意为:103 条我希望早点知道的人生忠告。

此为中译版,非常推荐阅读,在这里摘录我个人认为有启发的几句:

  1. 99%的时间里,真正关键的时刻就是此刻。
  2. 除了你以外,没有人会真的记得你拥有什么东西。
  3. “但是”之前的话都是废话。
  4. 当你原谅其他人的时候,他们不一定会知道这件事,但你自己却会被治愈。原谅不是我们给予他人的东西;而是我们给自己的礼物。
  5. 教育的一半作用是学习哪些东西可以被忽略。
  6. 不要相信你认为你相信的一切。
  7. 为了丰厚的回报,请对你完全不感兴趣的事物保持好奇。
  8. 重复别人是一个很好的开始。重复你自己则是一个令人失望的终点。
  9. 如果你对于一个主题的意见可以根据你另一个主题的意见预测得出,那你可能陷入了某种意识形态的掌控。如果你认真审视你自己的意识,你的结论其实是无法预测的。

质疑和信任

这是 Jeremy 两篇文章,主要讨论了本文作者发现的一个奇怪现象,Web 开发者普遍存在这样一种心态:他们会在项目中优先选择安装 npm 依赖,然后隐式地引入了更多的依赖,也就相当于完全地信任这些 npm 包以及它们的作者。

但作者认为更应该直接使用浏览器的原生特性,比如 HTML 元素、CSS 功能和 JavaScript API,虽然它们并不总是完美的,并且在开发过程中需要额外花费很多心思,但原生的特性更值得被信赖。

而关于这个话题的讨论将由此展开,有些人认为之所以普遍存在这样一种心态,是因为浏览器兼容性的缘故,譬如在 MDN 的 Overall Needs Ranking 中排名前 5 的需求有 4 个属于兼容性问题,以前的 jQuery 和 Bootstrap 就是在这样的需求下产生,而现在这种情况已经逐步得到改善,越来越多人开始倾向于使用浏览器原生特性,而不是借助第三方库,总得来说,Web 开发者对浏览器的信任正在恢复。

最后提出了一个设计原则:如果可用,请默认使用浏览器原生功能,而不是第三方库。

使用 Rust 开发单页应用程序

这是一篇使用 Rust 和 Yew 从 0 到 1 开发单页应用的文章,对于想要学习 Rust 和 Wasm 的同学可以看看。

有趣的链接

  • Exercism:一个学习编程语言的网站,非常有用。
  • 汉语反向词典:输入描述或词语,找到更多相似词语,帮助词穷的人更好地写作。
  • Ludwig:和上面的差不多,不过是针对英文的。
  • notefolio:一个韩国的 UI 设计网站。
  • natto:一个在线画 2D canvas 的工具。
  • 谷歌工程实践:顾名思义,Google 的通用工程实践,几乎涵盖所有语言和项目,内含 Code Review 以及代码编写指南。
  • Cantonese:一个高中生写的粤语编程语言。

周刊第10期:你如何在网上找到可靠的信息?

本周见闻

Git 2.36 亮点

4 月 18 日,Git 正式发布 2.36 版本,其中包含 96 个贡献者的改动,其中有 26 个新特性,在此罗列一些我比较感兴趣的新特性:

  1. git log –remerge-diff,更好地显示合并提交的差异,在此之前,如果我们查看一个具有合并冲突的提交差异时,输入的结果往往很难理解,而现在使用 --remerge-diff 则可以用 mergeconflictStyle 的样式显示差异。
  2. 更严格的存储库权限检查,在 2.35 版本时,出现了两个安全漏洞,此漏洞会影响在多用户计算机上工作的用户,这可能会导致某个用户在其他用户的存储库上执行任意命令,在 Git 2.35.2 版本中发布了安全补丁,总得来说就是 Git 更改了默认行为,防止这种情况的发生,我们也可以通过最新的 safe.directory 配置来有意义地绕过这个行为。
  3. 我们都知道 git bisect 这个命令可以用于二分查找,快速定位引入 BUG 的提交,同时它也可以通过指定一个可执行脚本来自动化这个过程,git bisect run test.sh,但在此之前,Git 并没有检查指定的文件是否为可执行脚本,导致 bisect 运行出错,该问题现在得到修复。
  4. 还有很多,感兴趣自行了解。

问HN:你如何在网上找到可靠的信息?

在 hacker news 上看到的一个帖子,在信息爆炸的时代,我们如何在网上找到可靠的信息?总结出了一些自认为有用的观点:

  1. 在信息源寻找「信息」,而不是「观点」,我们根据这些「信息」,得到我们自己的「观点」,同时以开放的心态去验证自己的「观点」,留意那些与你不同观点的人。
  2. 永远不要相信单一的信息来源,譬如不要只看国内的媒体:)
  3. 摆脱信息茧房,学习使用 RSS 订阅你感兴趣的内容,但是如何寻找有用的信息源也是一大难题。
  4. 尝试使用英文搜索你的问题,如果 Google 不好用,可以试试 Teclis

一些 tips

JavaScript 根据背景色显示对应的文字颜色

在 2022 的今天,dark 模式已经成为任何一个关注用户体验的应用不可或缺的功能,其中有一个很常见的需求就是根据背景颜色决定对应的文字颜色,这里就简单讲述一下我的实现思考。

首先我们需要知道任何颜色都有对应的灰度值的,得到颜色对应的灰度以后,就能知道该颜色是属于偏亮还是偏暗。

而获取灰度值的公式为:(0.299 * r + 0.587 * g + 0.114 * b) / 255

下面我们简单测试一下:

1
2
3
4
5
6
7
8
9
10
11
/**
* 从 rgb 获取灰度
*/
function getGrayLevelFromRgb([r, g, b]) {
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
}

console.log(getGrayLevelFromRgb([255, 255, 255])) // 1
console.log(getGrayLevelFromRgb([0, 0, 0])) // 0
console.log(getGrayLevelFromRgb([255, 0, 0])) // 0.299
console.log(getGrayLevelFromRgb([18, 18, 18])) // 0.07058823529411765

而我们只需要设定一个阈值,大于这个阈值的就属于 light,否则为 dark 即可。

这里我设定的阈值是 0.85,这个阈值具体是多少根据实际情况而定。

在 Vim 中保存只读文件

1
:w ! sudo tee % > /dev/null

Mac 在 dock 隐藏特定应用

有时候我们想要在 Mac 的 dock 中隐藏特定应用,可以这样:

1
sudo lsappinfo setinfo -app XXX ApplicationType=UIElement

分享文章

怎么花两年时间面试一个人

这是一篇 11 年前的文章(2011),但在今天看来仍然非常有用,本文比较长,但我还是建议你阅读。

这里简单摘要一些本文内容,本文开宗明义地提出一个问题:招聘难,难于上青天

这是因为:

  1. 最好的人也许不投简历,就决定去哪里了。所以要在他们做决定前找到他们。
  2. 比较差的会投很多次简历,找不到工作的时间越多,投的简历越多,给整个 pool 带来很多噪音,top10% 的简历也许根本不算全部人的 top10%。

公司招到靠谱的人非常重要,所以,招聘也许是一个公司决策当中最最重要的一个环节

本文作者就根据他的一些经验,给出了一些关于如何做招聘的建议。

最后作者给出如何花两年时间面试一个人的方法,那就是看面试者平时的积累,推荐的方式是:书 + GitHub,这样足以相当两年左右的面试。

你应该阅读学术计算机科学论文

作为一名在职程序员,你需要不断地学习,你可以通过查看教程、文档、StackOverflow,以及你可以找到的任何帮助你更好地编写代码并保持最新技能的内容。但是,你试过深入研究计算机科学论文来提高你的编程能力吗?

虽然以上提到的教程可以帮助你立即编写代码,但阅读学术论文能够帮助你了解编程的来龙去脉,从空指针到对象,这些编程中大部分日常使用的功能都可以追溯到上世纪 60 年代,未来的创新一样建立在今天的研究之上。

所以,不妨试一下直接通过阅读论文去了解这些编程的知识,如果认为阅读论文还是太难,可以试一下观看这些演讲:PapersWeLove - YouTube

WebAssembly 使用JavaScript 垃圾回收器

我们知道,JavaScript 自带有垃圾回收机制,而 WebAssembly 却没有这个机制,因为它在比较底层的环境中运行,我们只能靠自己分配内存。

目前有一个关于 WebAssembly 的提案,涉及实现一个垃圾回收机制,但可惜还处于 Stage 2,仍没有浏览器实现该功能。

而本文作者发现了一个取巧的方式可以实现这个需求,那就是使用 WeakRefs,总的来说就是通过 WeakRefs 特性,自行实现一个永久循环的函数,去做垃圾回收的事情。

感兴趣可以阅读相关实现代码

有趣的链接

  • IT Tools:一个开发者工具,包含日常使用的功能,如 Base64 转换、QR Code 生成,URL 解析等,使用体验极佳。
  • Operator Lookup:输入一个 JavaScript 的操作符(e.g. +、=>),将会解释该操作符的作用,对初学者非常有用。
  • Git Explorer:通过的问答选择的方式寻找你想要的 Git 命令,从此使用 Git 命令不再求人。
  • macOS Setup Guide:本指南介绍了在新 Mac 上设置开发环境的基础知识。旨在供所有人用作设置环境或安装语言/库的指南。
  • Stage:一个设计工具。

周刊第9期:Web 发展中的 100 个重大事件

本周见闻

Web 发展中的 100 个重大事件

自 2008 年 Chrome 浏览器正式发布以来,到现在 Chrome 已经发展到第 100 个版本了,为此还开发了一个网站,该网站展示了从 2008 年 Chrome 浏览器发布以来的 100 个对于 Web 发展的重大里程碑事件,譬如 GitHub 一周年、Node.js 发布、Flexbox 提案等,有兴趣可以看看。

我们如何失去 54K 的 GitHub stars

相信大家都知道 httpie 这个命令行工具,近日,由于维护者误操作将仓库设置为私有仓库,导致 54K 的 stars 被清零,经与 GitHub 官方沟通后,被告知无法恢复,截止今日(2022-04-17)已经重新涨回 12.7K。

httpie 在吐槽之余,还顺便教了一下 GitHub 做产品:

  1. UI/UX 设计,在设置为私有仓库时,告知用户会损失哪些数据。
  2. 数据库的软删除设计。

一些 tips

Chromium 的 DNS 缓存时间

Chromium 的 DNS 缓存时间大概在一分钟左右:

1
2
// Default TTL for successful resolutions with ProcTask.
const unsigned kCacheEntryTTLSeconds = 60;

DNS 的解析过程比较复杂,有兴趣可以看这个:Chrome Host Resolution,或者简单看下这两张图:

img

img

上图源自本站一篇旧文:在浏览器输入 URL 回车之后发生了什么(超详细版)

如果想要查看浏览器 DNS 配置的详细信息,可以按照以下流程:

  1. 打开:chrome://net-export,开始记录,打开任意一个网站发起请求,导出 JSON 文件。
  2. NetLog Viewer 导入查看,DNS 栏目。

ECMAScript 提案 - 通过复制改变数组

这篇博客文章描述了 Robin Ricard 和 Ashley Claymore 提出的 ECMAScript 提案 “Change Array by copy”。它为 Array 和 TypedArray 提出了四种新方法:

  • .toReversed()
  • .toSorted()
  • .toSpliced()
  • .with()

大多数 Array 方法是无副作用的 – 它们不会更改调用它们的数组,例如:filtermap 等。

但也有副作用的方法,例如:reversesortsplice

因此新加入的三个方法为上述三种方法提供了无副作用版本,除此之外还引入了一个新方法:with。

它是下面这段代码的无副作用版本:

1
2
3
4
arr[index] = value

// 无副作用
arr.with(index, value) // 返回一个新的 array

CSS 父选择器 - :has()

在以前, 我们无法根据父元素是否包含某个子元素时决定父元素的样式。

譬如,我们希望在 .card 有子元素 img 时设置特定样式:

1
2
3
4
5
6
7
<div class="card">
<img src="a.jpg" >
</div>

<div class="card">
<p>card text</p>
</div>

我们可以使用 :has()

1
2
3
.card:has(img) {
border: 1px solid red;
}

其实 :has() 不止可以用于检查父元素是否包含某个子元素,还可以检查后面的元素:

1
2
// 检查 h2 后面跟着 p
.card h2:has(+ p) { }

但遗憾的是,截止目前(2022-04-18)只有 Safari 15.4和 Chrome Canary 支持该特性,详见 caniuse

分享文章

React 18 允许组件渲染 Undefined

在 React 18 之前,如果我们这样渲染了一个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//Shape.jsx

import React from 'react';
import Circle from './Circle';
import Square from './Square';

function Shape({type}) {
if(type === 'circle') {
return <Circle />
}
if(type === 'square') {
return <Square />
}
}
export default Shape;

//App.jsx

function App() : ComponentType {
return(<Shape type="rectangle"/>)
}

由于 Shape 组件返回 Undefined,我们将得到以下报错信息:

1
Error: Shape(...): Nothing was returned from render. This usually means a return statement is missing. Or, to render nothing, return null.

为了修复报错,我们必须显式返回 null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react';
import Circle from './Circle';
import Square from './Square';

function Shape({type}) {
if(type === 'circle') {
return <Circle />
}
if(type === 'square') {
return <Square />
}
+ return null;
}
export default Shape;

但随着 React 18 的发布,即便组件未返回任何内容,也不会引发运行时错误。

基于以下三点原因,使 React 18 作出此改动:

  1. 与其抛出错误,不如使用 Lint 工具

    • 渲染 Undefined 报错这个机制是在 2017 年加入的,当时类型系统和 Lint 工具还没开始流行,但现在我们完全可以使用 ESLint 等工具帮我们处理这些类型的错误。
  2. 很难创建正确的类型,考虑以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //Shape.jsx 
    const Shape = ({ children }: ComponentType): ComponentType => {
    return children;
    }

    //App.jsx
    function App(): ComponentType {
    return (<Shape />);
    }

    我们必须在 ComponentType 类型将 Undefined 排除在外,但更好的解决方法就是允许渲染 Undefined。

  3. 保持一致的行为

JavaScript 中 RegExp 与 String.replace 的神奇特性

下面这段代码的执行结果是什么呢?

1
2
3
4
5
6
var regexp = /huli/g
var str = 'blog.huli.tw'
var str2 = 'example.huli.tw'

console.log(regexp.test(str)) // ???
console.log(regexp.test(str2)) // ???

相信很多人都会认为两个都是 true,但答案是 true 和 false,即便你写成这样,第二个输出结果也是 false:

1
2
3
4
5
var regexp = /huli/g
var str = 'blog.huli.tw'

console.log(regexp.test(str)) // true
console.log(regexp.test(str)) // false

这是因为 RegExp 是有副作用的,以下为 MDN 原话:

如果正则表达式设置了全局标志,test() 的执行会改变正则表达式 lastIndex 属性。连续地执行test()方法,后续的执行将会从 lastIndex 处开始匹配字符串,(exec() 同样改变正则本身的 lastIndex 属性值).

以下代码证明了这点:

1
2
3
4
5
6
7
var regex = /foo/g;

// regex.lastIndex is at 0
regex.test('foo'); // true

// regex.lastIndex is now at 3
regex.test('foo'); // false

再来看另外一段代码:

1
2
3
4
5
6
7
var str = '4ark'

var result = /\w+/.test(str)

str = ''

// 我们还能拿得到 str 之前的值吗?

答案是可以的,因为 RegExp 上有一个神奇的属性:RegExp.input

除此之外,还有这些:

  1. RegExp.lastMatch
  2. RegExp.lastParen
  3. RegExp.leftContext
  4. RegExp.rightContext

但是需要注意,这些特性是非标准的,请尽量不要在生产环境中使用它!

另外原文还有关于 String.replace 的神奇特性:使用字符串作为参数,简单来说就是:

1
2
3
4
5
6
7
8
9
10
const str = '123{n}456'

// 123A456
console.log(str.replace('{n}', 'A'))

// 123123A456,原本 {n} 的地方变成 123A
console.log(str.replace('{n}', "$`A"))

// 123456A456,原本 {n} 的地方变成 456A
console.log(str.replace('{n}', "$'A"))

在用户离开页面时可靠地发送 HTTP 请求

我们希望在用户离开当前页面时发送一个 HTTP 请求,这是一个非常常见的需求,譬如页面埋点等。

但根据 Chrome 页面的生命周期显示,在页面终止运行时,无法保证进程内的请求会成功,因此,在离开页面时发送请求可能并不可靠,如果我们依赖这个行为,则会出现潜在的重大问题。

通过下图可看出在页面离开时,请求会被取消掉:

在“网络”选项卡中查看 HTTP 请求失败

为什么请求会被取消呢?下面是 Chrome 对于页面终止生命周期(Terminated)的描述:

A page is in the terminated state once it has started being unloaded and cleared from memory by the browser. No new tasks can start in this state, and in-progress tasks may be killed if they run too long.

Possible previous states:
hidden (via the pagehide event)

Possible next states:
NONE

简单来说就是一个页面被卸载并从内存清除时,它就处于终止状态,在这种状态下,没有新的任务可以启动,正在运行的任务如果运行时间过长,则有可能会被 killed 掉。

那我们应该如何解决这个问题呢?有下面几种方案:

  1. 阻塞页面跳转,直到请求被响应:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
document.getElementById('link').addEventListener('click', async (e) => {
e.preventDefault();

// Wait for response to come back...
await fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: 'data'
}),
});

// ...and THEN navigate away.
window.location = e.target.href;
});

但这样也有很明显的缺点,1)损害用户体验;2)没有包含所有页面离开行为,例如关闭浏览器 tab。

  1. 使用 Fetch 的 keepalive 选项,使请求继续保留,即便页面已终止。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<a href="/some-other-page" id="link">Go to Page</a>

<script>
document.getElementById('link').addEventListener('click', (e) => {
fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: "data"
}),
+ keepalive: true
});
});
</script>
  1. 使用 Navigator.sendBeacon() 方法
1
2
3
4
5
6
7
8
<a href="/some-other-page" id="link">Go to Page</a>

<script>
document.getElementById('link').addEventListener('click', (e) => {
const blob = new Blob([JSON.stringify({ some: "data" })], { type: 'application/json; charset=UTF-8' });
navigator.sendBeacon('/log', blob));
});
</script>
  1. 使用 a 标签的 ping 属性
1
2
3
<a href="http://localhost:3000/other" ping="http://localhost:3000/log">
Go to Other Page
</a>

点击该链接后,它会自动发出一个 POST 请求,并将 href 属性放在请求头中:

1
2
3
4
5
6
headers: {
'ping-from': 'http://localhost:3000/',
'ping-to': 'http://localhost:3000/other'
'content-type': 'text/ping'
// ...other headers
},

但有如下限制:

  1. 只能在 a 标签上使用
  2. 浏览器支持很好,但 Firefox 除外 :(
  3. 无法自定义发送的数据…

如果选择使用哪个方法呢?文中还给出了一个很好的提示:

  • 以下情况,推荐使用 fetch + keepalive
    • 需要自定义 header 和请求内容
    • 希望发出 GET,而不只是 POST
    • 需要支持较旧的浏览器,并且已有 fetch 的 polyfill。
  • 以下情况,推荐使用 sendBeacon()
    • 只是简单的请求,不需要太多的自定义内容
    • 喜欢更干净、更优雅的 API。
    • 您希望保证您的请求不会与应用程序中发送的其它高优先级的请求竞争。

有趣的链接

  • Turborepo:Turborepo 是一个针对 JavaScript 和 TypeScript 代码库的高性能构建系统。

周刊第8期:阅读 ECMAScript 规范

阅读 ECMAScript 规范

有必要先向部分初学者解释一下 JavaScript 和 ECMAScript 的区别,最开始 ECMA 仅是 European Computer Manufacturers Association (欧洲计算机制造商协会)的首字母缩写,不过随着计算机的国际化,组织的标准牵涉到很多其他国家,因此这个组织已经改名为 Ecma 国际,所以现在的 Ecma 本身就是一个名字,不再是首字母缩写。

ECMAScript 与 JavaScript 其实就是同一个东西,只是因为 JavaScript 这个名称已经被 Sun 公司注册了商标,并且不开放给 Ecma 协会使用,所以 JavaScript 的标准只能叫做 ECMAScript,而 JavaScript 可以看做是 ECMAScript 规范的一种实现。

我们为什么要学习阅读 ECMAScript 规范呢?如果你只是一名 JavaScript 的初学者,你确实没有太大的必要去阅读 ECMAScript 规范,只需要通过阅读 MDN 文档就能学习如何编写 JavaScript,但随着 JavaScript 水平的提升,我们会越来越不满足于使用,我们会想要知道更多内部的细节,然而并不是所有的 JavaScript 细节都会在 MDN 文档上说明。

下面我就举个在工作中真实遇到的场景,以此阐述我们为什么需要阅读 ECMAScript 规范。

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
const obj = {
300: 300,
100: 100,
200: 200,
50.5: 50.5
}

for (const key in obj) {
console.log(obj[key])
}

你觉得这段代码的输出顺序是什么呢?答案是:100,200,300,50.5

这是为什么呢?

for…in 的 MDN 文档中,无法得到我们想要的答案,这时候就只能通过阅读 ECMAScript 规范:

image-20220408010524823

通过以上信息我们得知,JavaScript 在遍历一个对象的时候,它将按照如下规则执行:

  1. 创建一个空的列表用于存放 keys
  2. 将所有合法的数组索引按升序的顺序存入
  3. 将所有字符串类型索引按属性创建时间以升序的顺序存入
  4. 将所有 Symbol 类型索引按属性创建时间以升序的顺序存入
  5. 返回 keys

PS:当然 for…in 是不会返回 Symbol 类型的属性的,需要使用 Object.getOwnPropertySymbols()

以上源自我遇到的一个真实案例,详见《一行 Object.keys() 引发的血案》

相信大家都已经非常清楚学习阅读 ECMAScript 规范的重要性,可 ECMAScript 规范也不是这么容易阅读的,所以这里提供了一些文章,帮助你快速学习阅读 ECMAScript 规范:

总之,与本文一样带着问题去阅读,往往能够事半功倍。

《编程语言的设计与实现》—— 松本行弘

此书是 Ruby 语言的创造者 —— 松本行宏在《日经Linux》杂志上的连载整合而成,主要介绍了新语言 Streem 的设计与实现过程。作者从设计 Streem 这门新语言的动机开始讲起,由浅入深,详细介绍了新语言开发中的各个环节,以及语言设计上的纠结与取舍,其中也不乏对其他编程语言的调查与思考,向读者展示了创建编程语言的乐趣。

笔者现在刚看完第二章,不过也可以谈谈我的阅读感悟:作为一名野生前端,我对编译原理可谓是一窍不通,顶多也就写个 Babel 小玩具的水平,像《编译原理》这种专业书,我是连前十页都啃不下去,好在日系书籍有一个很大的特点就是:浅显易懂,此书也不例外,在前两章就带领读者如何通过 lex 进行词法解析,然后通过 yacc 进行语法解析,这过程还会将编译原理中的一些知识带出来,譬如 BNF(巴科斯范式)、窥孔优化等。

除了编译原理以外,我们还可以通过本书学习如何站在语言设计者的角度去思考语言的特性,为什么要这么设计,从而使我们的视野更加开阔,所以建议每一位开发者都阅读本书(对我这种野生程序员尤为重要)。

本周见闻

CSS 属性使用次数排行榜

Chrome 使用匿名使用统计数据计算每个在 Chrome 浏览器加载的页面中 CSS 属性出现的次数,数据的实时性大概在 24 小时之内。

以下截取部分排名靠前的 CSS 属性:

image-20220408000057438

一些 tips

为什么 HTTP 301 后会把 POST 转为 GET?

根据 RFC 7231, section 6.4.2: 301 永久重定向 指出:

Note: For historical reasons, a user agent MAY change the request
method from POST to GET for the subsequent request. If this
behavior is undesired, the 307 (Temporary Redirect) status code
can be used instead.

简而言之就是因为历史原因,当某些 HTTP/1.0 客户端收到该状态码时,可能会将 POST 方法改为 GET 方法,继续向新地址发出请求,这是错误的实现——故而后续标准引入了 HTTP 307

所以最好只在 GETHEAD 方法时使用 301,其他情况使用 307 或者 308 来替代 301。

JavaScript 的数字安全范围

你会如何解释这段代码:

1
9007199254740992 === 9007199254740993 // true

我们知道 JavaScript 的数字是用 64 bit 來存,而且遵循的规格是 IEEE 754-2019,既然用 64 bit 来存,那可以表示的数字自然是有限的。

我们可以用 Number.MAX_SAFE_INTEGER 表示 JavaScript 最大正整数的安全范围,也就是 2^53 - 1 = 9007199254740991

这里所说的安全指的是:能够准确区分两个不相同的值,例如 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 这在 JavaScript 上是成立的,但它在数学上是错误的,我们可以使用 Number.isSafeInteger() 来判断一个数字是否是一个「安全整数」。

需要注意的是,最大的安全范围不代表 JavaScript 只能存储 Number.MAX_SAFE_INTEGER 这么大的数字,其实我们最大可以存储 Number.MAX_VALUE 也就是 1.7976931348623157e+308 ,只是它不在安全范围之内罢了。

总之,对于一些比较大的数字(譬如 uuid 这类),优先考虑是否使用 String 类型,如果一定要数字类型,可以了解下 BigInt

为什么「Enter键」要被翻译为「回车键」?

其实「回车」并不是 “Enter” 的翻译,而是 “return” 的翻译。这个 return 其实指的是 “↵+Enter” 中箭头的意思,换言之,Enter 并不是“↵”的一个解释,严格讲 “Enter” 和“↵”是这个键的两种不同的名称,也即两个不同的用途。

之所以会被翻译成「回车」,是因为现代电脑键盘是从过去的打字机上继承过来的,在过去的机械打字机上有个部件叫「字车」,每打一个字符,字车前进一格,打完一行后,我们需要让字车回到起始位置,而 “Return” 键最早就是这个作用,因此被翻译为「回车」。

有兴趣可以看看这个视频:

分享文章

DeepL Api 设计中的欺骗战术

本文作者通过逆向 DeepL 的 Windows 客户端(C#),破解了 DeepL 如何实现接口防滥用。

直接说结论,其实 DeepL 并没有使用一些常规的方法(譬如 token、签名等)去实现接口防滥用,而是通过两个非常取巧的方法去把开发者绕晕:

  1. timestamp 参数并不是一个真实的时间戳,而是通过时间戳和源文本的长度进行伪造的,公式是:ts - ts % i_count + i_count,由于与真实的时间戳仅有毫秒部分的差别,一般人无法直接看出端倪。
  2. id 参数就是一个随机数,只不过后续的请求会在此基础上 + 1,并且这个 id 会决定文本中一个小小的、微不足道的空格。但由于我们通过拿到结果后都会先对 JSON 进行一下格式化,所以很容易忽略这种细节。

如果不是逆向源代码,相信一般人很难发现这两点细节,不得不感叹 DeepL 工程师的脑洞。

Cloudflare 如何将网站加载时间缩短 30%

本文介绍 Cloudflare 在 2021 年发布的一个新特性:Early Hints,准确来说它是一个 Web 标准,它定义了一个新的状态码 103。

其最核心的功能是:在服务器响应 200 时,先向客户端响应 103,其响应内容包含这个网页所需呈现内容的资源提示,客户端可利用此提示加载页面速度,如下图:

img

在上面提到的 RFC 中可看到 HTTP 103 的响应大概长这样(其中可能会有多个 103 响应):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Client request:

GET / HTTP/1.1
Host: example.com

Server response:

HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script

HTTP/1.1 200 OK
Date: Fri, 26 May 2017 10:02:11 GMT
Content-Length: 1234
Content-Type: text/html; charset=utf-8
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script

<!doctype html>
[... rest of the response body is omitted from the example ...]

我们可以在 Chrome 94 及更新版本使用该特性,关于更多内容可见:early-hints

有趣的链接

  • js-quirks:关于 JavaScript 的一些怪癖语法说明,对于想要实现 JavaScript 解析器的同学很有帮助和启发。
  • Moonvy 月维:探索「设计生产力」之道,与你一起, 创造设计师与开发者的必备工具。
  • QUOKKAQuokka 是一个调试工具,可以为您正在编写的代码提供实时反馈(可惜大部分功能都要收费。
  • Adobe Creative Cloud Express:Adobe 新推出的一个设计工具,可提供快速「去除背景」、「转换为 GIF」、「合并 PDF」以及更多高级操作。

周刊第7期:使用新的周刊模板

使用新的周刊模板

在本期开始采用新的周刊模板,新增了两个模块:

  • 本周见闻:一些有趣的事情、观点,与「分享文章」最大的区别在于它们通常是一些比较简短的信息。
  • tips:一些有用的技巧,可以帮助你提高工作效率。

如此,周刊内容就显得更加丰富,可以分享更多元的信息了。

本周见闻

React v18 正式发布

  1. 自动批处理

    批处理是指 React 将多个状态更新合并到单个 re-render 中以获得更好的性能。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 在之前: 只有 React 事件是批处理的。
    setTimeout(() => {
    setCount(c => c + 1);
    setFlag(f => !f);
    // React 将渲染两次,每次状态更新一次(没有批处理的情况)
    }, 1000);

    // 在之后: setTimeout、Promise、原生事件这类异步操作都将合并到一次 re-render 中。
    setTimeout(() => {
    setCount(c => c + 1);
    setFlag(f => !f);
    // React 只会在最后重新渲染一次(这就是批处理!)
    }, 1000);

    了解更多:https://github.com/reactwg/react-18/discussions/21

  2. Transitions

    这是 React 中的一个新概念,用于区分紧急和非紧急更新(过渡更新)。

    • 紧急更新:反映了直接交互,如输入、单击、按下等。
    • 过渡更新:将 UI 从一个视图转换为另一个视图。

    通常,为了获得最佳用户体验,单个用户输入应同时导致紧急更新和非紧急更新。您可以在输入事件中使用 startTransition API 来通知 React 哪些更新是紧急的,哪些是过渡的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import {startTransition} from 'react';

    // 紧急,显示输入的内容
    setInputValue(input);

    // 将内部的任何状态更新标记为转换
    startTransition(() => {
    // 过渡:显示结果
    setSearchQuery(input);
    });

    startTransition 中包含的更新操作都将作为过渡更新进行处理,如果出现更紧急的更新,则会打断之前的过渡更新,只渲染最新的。

    了解更多:https://reactjs.org/docs/react-api.html#transitions

  1. Hooks

    1. useId:用于在客户端和服务器上生成唯一 ID
    2. useTransition:标记为过渡性更新。
    3. useDeferredValue:允许您延迟重新渲染树的非紧急部分,类似于防抖。
    4. useSyncExternalStore:允许外部存储通过强制对存储的更新为同步来支持并发读取。
    5. useInsertionEffect:允许 CSS-in-JS 库解决在渲染中注入样式的性能问题。

关于 React v18 更多新功能请查看:React v18.0 – React Blog

CSS 新提案 - 共享元素转场效果

CSS 的一个新提案,为切换页面提供平滑加载动画,无论是 SPA 还是 MPA 都适用,效果如下:

关于更多可了解:shared-element-transitions/explainer.md

尊重用户的默认字体大小

在处理文本和 CSS 时,我们通常都会默认设置一个固定的字体大小:

1
2
3
html {
font-size: 16px;
}

这在大部分 WEB 网站都没有问题,但是对于内容网站(如新闻网站、论坛、博客等),这可能会导致可用性问题,因为每个人设置的默认字体大小有可能不一样,比如在 Chrome 就可以通过 chrome://settings/fonts 更改字体大小,所以更好的做法是:

1
2
3
html {
font-size: 100%;
}

然后在内部元素采用 emrem

TypeScript 的 Web API 类型声明是怎么来的?

在 TypeScript 中,所有的 Web API 类型声明都在 lib.dom.d.ts 中实现,一共多达 18877 行,它当然不是手动编写的,而是利用 webref 进行生成机器可读的 Web 标准,每 6 小时自动生成一次,然后利用 TypeScript-DOM-lib-generator 自动生成 lib.dom.d.ts。

一些 tips

使用 Lighthouse 展示网站的 JavaScript 依赖关系

熟悉 Webpack 的前端同学应该知道有一个插件叫做 Webpack-bundle-analyzer,它用于展示每个 npm 包的体积。

现在我们也可以通过 Lighthouse Treemap 查看网站的 JavaScript 依赖关系和文件体积,效果如下:

使用方法:在 Chrome 使用 Lighthouse 检查,然后在结果页中点击「View Treemap」即可。

分享文章

我讨厌的5个编码面试问题

本文列举几个作者最不喜欢的五类面试题目:

  1. 一些不重要的琐事

    这一类面试题通常是一些实际工作中很少遇到的场景,比如:

    1
    2
    3
    4
    5
    const x = {};
    const y = {};
    x.__proto__ = y;
    y.__proto__ = x;
    console.log(x.field);

    首先 __proto__ 并不是一个标准的属性,实际工作中也应该避免使用它来修改原型链,能够正确回答它并不能真正显示候选人的 JavaScript 水平,只能体现他看过多少面试题。

    这些题目通常关注于一些 JavaScript ES5 之前的糟粕,但现在已经是 2022 年了,我们没有必要再问这类问题。

  2. 具体的细节

    这类问题通常过于关注细节,比如:

    • 迁移到 Webpack 6 会遇到什么问题?
    • 如何检查浏览器对服务器发送事件的支持?

    这些问题,即便候选人真的在工作中解决过,除非事先准备好,否则也很难完整地回答出来,但我们都擅长通过搜索引擎解决这类问题。

    这些题目极有可能是面试官最近解决过的一些棘手问题,他对此记忆犹新,但是根据这些问题来判断候选人的能力是不公平的,因为即便候选人回答不出来,也不代表他无法解决此类问题,这只是双方信息不对称所导致的。

    所以面试官更应该着重关注于候选人最近解决过什么棘手问题,以此判断候选人解决问题的能力。

  3. 混淆问题

    这些问题通常具有一个标准的答案,比如:

    1. Number 和 Object 之间有什么区别?

    这个题目的答案仅仅是:Number 是不可变的。

    但候选人可能会认为这是一个开放式的题目,因此会从各方面对它们进行对比,这严重浪费时间。

    同样的,这些问题也体现出候选人的面试经验,只有他被问过几次同样的题目,才熟练地知道它具有一个标准的答案。

    所以面试官应该更直接地问:哪些 JavaScript 类型是不可变的。

  4. 实现问题

    这类问题通常与浏览器实现细节相关,但它们不在规范里面:

    • console.log(Object.keys({ x: 0, y: 0 }).join()) 问 x 和 y 哪个在前面?

    大多数人都知道正确的答案:根据添加顺序进行排序。

    但此类特性依赖于 JS 引擎的实现,在 ECMAScript 规范中并没有明确定义。

    所以面试官在问这类题目的时候,不应该持有标准化答案的心态,它更应该是一个开放式的题目。

    PS:我曾写过一篇文章深入剖析 Object.keys 的规范:《一行 Object.keys() 引发的血案》。

  5. 缺少上下文

    开放式问题是你在面试中可以问的最好的问题之一,因为它们具有挑战性,并能够真正体现候选人解决问题的能力,然而这些问题取决于面试官个人观点,否则容易引起反作用。

    例如,这个函数有什么问题:

    1
    2
    3
    4
    5
    6
    function map(arr, fn) {
    for (var i = 0; i < arr.length; i++) {
    arr[i] = fn(arr[i]);
    }
    return arr;
    }

    这个问题对于不同的人具有不同的观点,有些人觉得这段代码问题一大堆,比如:

    1. 为什么使用 var 而不是 let
    2. 为什么不使用 for...of ?
    3. 它具有副作用,不应该直接修改 arr
    4. 为什么不直接使用 .map() ?

    但也有人觉得它没有任何问题,既然它能够正常工作,我们为什么需要重构它?仅仅是为了让它看上去更好吗?我们的目标是什么?没有明确目标的重构就是浪费时间!

    所以,双方都没有错,因为它完全取决于上下文。

    所以,当你提出一个开放式问题时,要么放下你预期的答案,专注于解决问题的过程,要么引入缺失的要求来指导你想要的解决方案。

    最后,作者给出了一个如何改进面试题目的建议:

    img

不良面试官的七个习惯

此文接着上一篇《我讨厌的5个编码面试问题》,列举几个面试官不好的习惯:

  1. 骄傲的自负

    有些面试官通常抱有一种「既然是我在面试你,那么我的能力自然比你强」的心态,他们往往摆出一副居高临下的样子,但你要清楚,你掌握所有面试问题的答案,这本就是一种信息不对称,所以你不应该抱有这种想法。

  2. 专注于答案

    就像考试一样,面试官列出所有的面试题目,照本宣科地问问题,这类问题通常具有标准答案,但真正的工作中很少是考试一样解决问题的,并且这类问题都可以通过搜索引擎解决。

    我们更应该倾向于开放式的题目,专注于候选人解决问题的过程,比如问「如何设计一个 Swiper」会比「具有哪些触摸事件」更合适。

    此类问题其实还蕴含了一个顾虑:不信任候选人,认为他们缺乏这些基础知识。

  3. 不给出任何提示

    试问当你身边的同事在某个问题卡住的时候,你会选择帮助他还是立即解雇他?所以我们应该引导候选人,而不是让他一个人苦苦挣扎。

  4. 规划不善

    面试前没有提前规划好面试流程、或者是面试流程安排得太紧凑都会导致面试效果不佳。

    假设你们明显有可能在某个问题上进行深入地探讨,千万不要仅仅因为你需要问更多初级的问题而打断候选人展示自己的机会。又或者你招聘的是高级开发人员,而候选者碰巧是一位非常不错的初级开发人员,你也不要轻易地错过。

  5. 忽略简历

    不要浪费候选人的宝贵时间,假设候选人是一位顶尖大学毕业、甚至有一个技术博客和开源项目,这都可以体现出候选人的专业技能,而面试官仍要花费近 30 分钟去问一些基础问题,这会让候选人觉得是在浪费自己的时间。

    诚然简历是可以造假,但仍不应该花费过多的时间去印证候选人是否具备这些基础知识,把时间花在更有价值的问题上。

  6. 过渡延长

    不要让面试时间过长,除非你有自信保证候选人愿意花费这么多时间。

  7. 避免群体面试的不良反应

    多个面试官时千万不要你一句我一句的,也不要显得某个面试官在场是完全没必要的,更好的做法是其中一位面试官负责主要的问题,其他面试官负责观察。

如何看待 ECMAScript 新提案 - Type Annotations

这个月有一个 ECMAScript 的新提案,可以在 JavaScript 中使用 TypeScript 部分类型声明,参见:tc39/proposal-type-annotations

此提案一经提出,在中英文社区都引起了不少的轰动,而《JavaScript for impatient programmers》的作者 Dr. Axel Rauschmayer 在这篇文章中提出了一些他的看法。

先展示一下这个提案的一些使用示例:

1
2
3
function add(x: number, y: number) {
return x + y;
}

很标准的 TypeScript 语法,而 JavaScript 将这样处理这些类型声明:

  1. 在运行时,JavaScript 引擎完全忽略它们 - 就好像它们是注释一样。
  2. 在开发时,类型检查器可以静态分析注释并警告开发人员有关潜在问题。

下面是本文作者的一些看法:

  • 优点:
    • 为类型声明标准化是很好的,并且将使该领域的工具和实验更容易。
    • 可以在不编译源代码的情况下使用 TypeScript 进行编程(例如)。在开发时只会进行类型检查。这将大大改善静态类型 JavaScript 的开发体验:
      • 执行时不需要中间文件。
        • 这在 Node 上特别有用 .js 您可以直接运行 TypeScript 文件。
      • 调试时,不需要源映射即可查看原始源代码。
      • .d.ts文件通常也不需要。
  • 缺点:
    • 像 TypeScript 这样的静态类型系统是完全可选的 JavaScript 之上的层,不会给 JavaScript 增加任何复杂性。
    • 该提案为该语言添加了许多新的语法。即使引擎忽略它,它们仍然必须能够解析它。升级 JavaScript 工具需要时间和精力。
    • 如果在将库部署到 npm 之前没有将 TypeScript(等)编译为 JavaScript,那么对于不喜欢 TypeScript 的人来说,浏览 TypeScript 开发人员编写的源代码将变得不那么愉快。
      • 为了帮助解决这个问题,从文件中删除所有类型批注可能成为文本编辑器支持的操作。

个人思考

我个人非常喜欢这个提案,也很希望这个提案能够最终进入到 ECMAScript 的标准中。但这背后仍会有无数的坑,比如 add<number>(4,5) 它也是合法的 JavaScript 代码,至于如何解决这类与现有代码冲突的问题,就让我们拭目以待吧。

有趣的链接

  • Git 飞行规则(Flight Rules):这是一篇给宇航员(这里就是指使用 Git 的程序员们)的指南,用来指导问题出现后的应对之法。

  • 里科的备忘单:一些语言、框架、工具的 TL;DR,帮助你快速了解某一门技术。

  • 访问指南:这是对可访问性的友好介绍!列出了很多有用的知识帮助你提高网站的可访问性。

周刊第6期:网络没有版本号

分享文章

一些本周阅读过的好文章、以及我的一些总结和个人思考;非常建议你直接阅读原文,毕竟一千个读者就有一千个哈姆雷特,而且我的理解可能是错的。

网络没有版本号

在过去一年时间里,我们经常听到 WEB3 ,以及相关的术语:如区块链、加密、NTF 等,让不少 WEB 开发者认为这项技术是未来的趋势,于是每个开发者都跃跃欲试,但本文作者认为我们应该对这个充满误导性的术语 「WEB3」持谨慎态度,因为网络并没有版本号,更没有某个权威的机构会定期更新网络的版本。

希望 WEB3 及其相关术语不会成为 WEB 开发者的简历上必备的流行词,对于大部分 WEB 开发者而言,该领域的专业知识是不必要的。

您在软件开发方面的经验水平将使您产生截然不同的观点和意见

假设你是一个初级开发者,你为了实现某个功能,而刚好有一个库/框架能够满足你的需求,所以你希望能够把这个库/框架加入到项目中,但比你更有经验的同事拒绝了这个建议,他认为没必要仅仅为了实现一个功能而添加一个库/框架。

你可能会认为这是一个短视的看法,他们不应该被困在什么都自己重新造轮子的旧观念上。

但也许他是这样思考问题的:

  1. 添加一个库,意味着增加系统的复杂度、意味着更多的风险。
  2. 需要及时跟踪这个库的安全漏洞、并在 breaking change 时更新现有的代码。
  3. 这个库的安全性如何?是否会有意外的情况发生?
  4. 添加这个库的成本和风险?成本是否会比自己开发更低?

个人思考

对于任何一个商业项目而言,不应该也不可能完全脱离开源社区的框架、类库,我们确实不可能所有事情都重新造轮子,开源类库的健壮性肯定比自己实现要强,但这也不意味着我们可以盲目地使用开源类库,即便要使用类库,也可能会面临多个不同选择,我们应该从生态社区、维护积极性、安全性等多方面进行考察和对比,最终选择出适合的方案。

当你在构建产品时,你应该听取谁的意见?

人们根据自己的背景来重视不同的东西。就比如对于笔记本电脑,不同的群体需要不同的功能:

  • 学生们想要一台价格实惠且重量轻的笔记本电脑(因为他们会把它带到课堂上)。
  • 程序员想要一台具有高分辨率屏幕,大量内存和全尺寸键盘的笔记本电脑。
  • 游戏玩家想要一个具有超强 CPU和 GPU 的电脑,他们也不介意笔记本电脑是否笨重。

本文作者提出我们在构建产品时,最应该听从谁的意见,以及如何听从:

  • 只接受为您的产品付费的人的产品反馈,很多人可能不同意这个观点,但是请看看 Feedbook 和谷歌,我们中的许多人都在每天使用它,但是 Facebook 和谷歌似乎不太关心我们的隐私,并在未经我们同意的情况下分享我们的信息。为什么?因为他们效忠的是广告商

在浏览器 devtools 中打印图片

Untitled

我们使用以下代码在 devtools 中利用 console.log 输出图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function getBox(width, height) {
return {
string: "+",
style: "font-size: 1px; padding: " + Math.floor(height/2) + "px " + Math.floor(width/2) + "px; line-height: " + height + "px;"
}
}

console.image = function(url, scale) {
scale = scale || 1;
var img = new Image();

img.onload = function() {
var dim = getBox(this.width * scale, this.height * scale);
console.log("%c" + dim.string, dim.style + "background: url(" + url + "); background-size: " + (this.width * scale) + "px " + (this.height * scale) + "px; color: transparent;");
};

img.src = url;
};

或者直接使用这个库:https://github.com/adriancooney/console.image

有趣的链接

  • CORS Tester:跨域对于前后端来说都是一个不可逾越的知识点,在实际项目开发中也必然会遇到各种各样的跨域问题,可以使用这个网站在线体验跨域的请求。

  • Effective Shell:一本学习 Shell 的在线书籍,适合任何一个想要入门 Shell 的同学。

  • Convert curl commands to code:将 CURL 转换到各编程语言的实例中,当然我们平常都会使用 Postman、PAW 等工具进行转换,不过也总有需要在线转换的时候把?

  • JavaScript for impatient programmers (ES2022 edition):在 JavaScript 世界中有很多非常经典的书籍,如 《JavaScript 权威指南》、《JavaScript 高级程序设计》、《你不知道的 JavaScript》 等,但我推荐这本更加现代化的书,包含了最新的 ES2022 新特性。

  • Charm:在 CLI 构建你的图形界面,真的很漂亮!

  • jless:一个更好地显示 JSON 的 CLI 工具,推荐使用!

  • DevToys:堪称开发者的瑞士军刀,提供了比如文件转换(JSON <> YAML )、编码解码、格式化(支持 JSON、SQL、XML)、哈希生成、UUID 生成、图片压缩多种功能。

周刊第5期:拖延症

拖延症

本期周刊延迟了两天,今晚抽空补上,内容会相对比较少。

反思一下,一个原因是这周比较忙,但主要还是因为自己的拖延症,希望以后能够克服这个问题。

分享文章

一些本周阅读过的好文章、以及我的一些总结和个人思考;非常建议你直接阅读原文,毕竟一千个读者就有一千个哈姆雷特,而且我的理解可能是错的。

Exhausting Exhaustive Testing

原文地址:《Exhausting Exhaustive Testing》 | openmymind

非常短的一篇文章,但作者的观点让我醒醐灌顶:编写有效的测试具有挑战性,每个测试用例都需要考虑最大化其价值。

举一个例子,比如我们有这么一个软删除用户的函数:

1
2
3
4
5
6
7
8
9
func deleteUser(id) (bool, error) {
tag, err := conn.Exec(`
update users
set status = 'deleted'
where id = $1
`, id)

return tag.RowsAffected() == 1, err
}

通常我们会如何测试这个函数?是不是这样:

  1. 插入一个新的用户
  2. 调用这个函数
  3. 判断这个新用户是否被删除

这样测试没有错,但是我们忽略了一点:如果这个函数把所有用户都删除了呢?是不是也能通过测试?

所以正确的测试方式是:插入两个用户,删除第一个用户,确保第二个用户没有被删除。

还有另外一个例子,假如我们要获取用户列表,SQL 是这样的:

1
2
3
4
5
select id, name
from users
where status = 'normal'
and customer_id = $1
order by name

通常我们可能是这样测试的:

  1. 插入一个新用户,设置 customer_id
  2. 调用函数
  3. 判断是否返回这个新用户

但作者认为至少需要插入四个新用户才能进行这个测试:

  1. 四个用户,其中一个被删除的 A、一个 customer_id 不符合的 B,剩余两个才符合条件
  2. 确保没有返回 A 和 B

个人思考

诚然我们都认同测试的重要性,但是如何编写测试是一门非常高深的学问,只是编写测试用例的时候只是流于表面的,那么其价值是非常低的,甚至是浪费时间的。

百度腾讯阿里真的是高科技企业吗?

原文地址:《百度腾讯阿里真的是高科技企业吗?》 | 马工

那么,这些潜力人才去了BAT,在干什么呢?腾讯的公众号文章《搞了运维开发这么多年,原来 Ping 还能这么玩儿!》揭示了部分真相:一个北大本科毕业生在腾讯研究一个1981年的协议ICMP,而这个协议因为不安全已经被大多数美国同行比如AWS给默认禁掉了。说句不客气的话,ICMP协议就是IT行业的回字的四种写法,让北大毕业生去研究ICMP协议,就是把他们变成孔乙己。

有趣的链接

  • AES加密/解密:一个在线 AES 加密/解密的工具。

  • PlantUML Editor:一个在线画 PlantUML 的工具。

  • OssArt:一个非常有意思的开源项目,它可以帮你打印出最早从 2010 年开始的 GitHub Activity 贡献图,让你的成就感满满。

Hg hooks 实践历程

故事的开始

相信使用 Hg 的同学们已经非常熟悉上面这几句话了,我们每次在提交或者拉取代码时总需要手动执行某些命令,实在繁琐。

但现在已经 2022 年了,难道就没有更好的解决方法吗?

有的,我们知道,在 Git 有个东西叫做 hooks(钩子),可以在特定事件发生之前或之后执行特定动作。

同样的,Hg 也有 hooks,不过并不像 Git 一样生态蓬勃发展,也没有太多现有的开源工具可供大家使用。

本文就来介绍一下我们从 0 到 1 的 Hg hooks 实践过程,同时也希望能够起到抛砖引玉的作用。

石器时代

在没有引入 Hg hooks 之前,我们常常会面对几种情况:

  1. 有同学在提交代码时忘记执行 yarn lintyarn test
  2. 修改了 project-config 的常量,却忘记通知大家,或者有人错过了这条信息。

这都有可能会导致其他小伙伴拉取代码后,发现页面上的某个功能突然异常,花费一段时间排查才发现原来是没有执行 yarn setup

可能有部分同学会想到,那我自定义一个命令在提交或者拉取代码时自动做这件事不就好了吗?

比如这样:

1
2
3
> yarn lint && hg commit -m "xxx"

> hg pull --update && yarn setup

这样也不是不行,但是会存在一些问题:

  1. 由于每个人的拉取代码的命令不一样,如果项目开发流程发生变化,则每个人都需要同步修改
  2. 有些同学习惯使用图形化界面,比如 SourceTree、vscode-hg 等,则无法自定义操作命令

因此,我们另辟蹊径,寻找更好的解决方案。

青铜时代

我们最主要想解决的问题就是:

  1. 在提交代码前自动执行 yarn lintyarn test,不通过则直接终止提交。
  2. 在拉取代码后,检测到如果 project-config 目录发生改动,则自动执行 yarn setup
  3. 还有更多:
    1. 检查 commit message 规范
    2. 统一代码的格式化风格

这些都可以通过 Hg hooks 解决,所以开始之前,我们先对 Hg hooks 做一个简单的认识。

Hg hooks 介绍

Hg hooks 能做什么,这次再介绍一遍:它可以在特定事件发生之前或之后执行特定动作。

特定事件,指的就是我们在对 Hg 仓库进行操作时的一些钩子,比如提交前(precommit)、提交后(commit),可以在这里查看全部 hooks 列表:hooks

下面介绍一下如何使用 hook,我们可以通过以下两个文件进行配置:

  1. ~/.hgrc:全局的,将对所有 hg 仓库起作用。
  2. 项目根目录的 .hg/hgrc :仅对当前仓库起作用。

比如我们想要实现一个简单的需求:在提交代码前进行 yarn lint

首先编辑 .hg/hgrc文件:

1
2
[hooks]
precommit = ./bin/hooks/precommit.sh # 这个路径是相对于项目根目录的

然后编写脚本 bin/hooks/precommit.sh(也可以使用 python):

1
2
3
4
5
6
7
8
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

yarn lint

# lint 没有通过直接退出
if [ $? -ne 0 ]; then exit 1; fi

这里需要特别指出,之所以需要重新声明 PATH 变量:

  1. hooks 脚本的运行环境取决于同学提交代码的地方,比如通过 SourceTree 提交,由于环境不一样,就可能会出现 yarn: command not found 的报错,参见:‘Git Command Not found’ in the custom action for SourceTree - Stack Overflow
  2. 每个同学安装 hg 的方式可能不一样,有通过 brew、pip、甚至自己手动编译的,它们的可执行文件路径不一样。
    • 可以通过 which hg 查看这个命令的可执行文件路径。

这样,一个简单的 hook 就配置完成了,这时候提交代码就会触发 precommit.sh

1
2
3
> hg commit -m "ci: precommit hooks"

$ eslint '**/*.js' --cache --fix

当 hook 脚本的 exit code 不为 0 的时候,则会终止当前的 Hg 操作,对于某些具有事务性的 hook(e.g. pretxncommit),还会自动进行回滚。

可以通过以下链接对 Hg hooks 进行更深入地学习:

Hg hooks 实践

提交代码前(precommit)

这里需要用到的 hook 是 precommit,它的运行时机在提交之前,exit code 非 0 时将终止提交。

precommit.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

if [ "$SKIP_LINT" = "1" ]; then
exit 0
fi

# 需要 lint 的项目
apps=(
'miniprogram'
'dashboard'
'core'
)

for app in "${apps[@]}"; do
# 判断是否修改该项目,无则跳过 lint
has_change=$(hg status | grep "${app}")

if [ -z "$has_change" ]; then continue; fi

cd "$app" || exit 1

yarn && yarn lint

# lint 是否报错,是则直接退出脚本
if [ $? -ne 0 ]; then exit 1; fi

cd -
done

# 针对当前修改或新增的文件批量进行 prettier 格式化
hg status | grep -E "^(M|A).*.(js|json|wxss)$" | sed 's|^M||g; s|^A||g' | xargs ./node_modules/.bin/prettier --write >/dev/null 2>&1

比较浅显易懂,由于是 Monorepo 架构,所以仅针对当前改动的子项目执行 yarn lint ,当 lint 不通过时终止提交;然后仅对当前变更的文件做 prettier 格式化,并且忽略这行命令的输出和错误。

ps:其实这里的 prettier 机制有点问题,原本的目的是仅格式化当前提交的文件,但 Hg 没有 staging area 的概念,故只能粗暴处理,如果有更好的解决方法欢迎指教。

  • hg commit 可以只提交指定的部分文件,所以是有 changed files 和 commited files 两个概念,但是没有找到办法获取 commited files,参见:Mercurial pre-commit hook: How to tell apart changed and committed files - Stack Overflow
  • 另一种思路:使用 pretxncommit 钩子,就可通过 $Hg_NODE 变量拿到当前 commit 的信息,但缺点是 pretxncommit 阶段将不能再对文件进行改动,则格式化后需要重新提交一遍。

随着版本迭代,在 precommit 钩子中增加了检测 utils、test 目录改动则自动执行单元测试 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 修改以下文件需要执行单元测试
apps=(
'miniprogram/utils miniprogram/test'
)

for app in "${apps[@]}"; do
dir=($app)
pass=0

for d in "${dir[@]}"; do
# 判断是否修改工具方法、测试用例,无则跳过
has_change=$(hg status | grep "${d}")

if [ -z "$has_change" ]; then continue; fi

# 同一个项目只执行一次
[ $pass -eq 1 ] && break

cd "$d" || exit 1
yarn && yarn test

# test 是否报错,是则直接退出脚本
if [ $? -ne 0 ]; then exit 1; fi

pass=1

cd -
done
done

拉取代码后(changegroup)

主要想解决的问题是:当拉取代码后,检测到 project-config 目录发生变更,则执行 yarn setup

首先要解决第一点,如何获取从远端拉取代码所改动的文件?有下面几种方法:

  1. hg incoming:显示远端中的新 commit

    • 缺点:该方法只是显示新的 commit,后面仍需要再进行一次 pull 才能将新 commit 拉取下来,导致拉取代码时间翻倍。
  2. hg pull:在拉取代码之后、进行 update 或 rebase 之前,通过 hg log 对比本地 head 和 远端拉取下来的 head。

  3. hooks:

    1. update:工作目录发生改变时,所以只要进行提交、储藏、切换分支都会触发,不考虑
    2. incoming:每一个新的 commit 被传入时都会触发一次,过于频繁,不考虑
    3. changegroup:在 push、pull、unbundle 时都会触发,但多个 commit 被传入也只会触发一次,可考虑
    4. 还有一些不太满足的 hooks 不一一介绍了。

在一开始,我们曾尝试使用第二种方法解决该问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
hgpl() {
no_shelve=$(hg shelve | grep "nothing changed")

hg pull

# 改动文件
regex='\bproject-config'

# 获取本次 pull 变更集的改动文件列表,判断是否有改动相关文件
# @link:https://stackoverflow.com/questions/3277334/what-files-will-be-changed-vs-added-when-i-do-an-hg-pull-and-hg-update
has_change=$(hg log --verbose -r .:tip | grep "files:" | grep -E "$regex")

# 参考 hg update --rebase 的实现,先尝试 rebase,如果不需要 rebase,则直接 update
# @link:https://stackoverflow.com/questions/35327163/what-is-the-rebase-command-used-in-hg-pull-rebase
# @link:https://www.mercurial-scm.org/repo/hg/file/tip/hgext/rebase.py#l2172
has_rebase=$(hg rebase -b . -d 'last(branch(.))' | grep "nothing to rebase")

if [ ! -z "$has_rebase" ]; then
hg update
fi

# 有改动相关文件,需要执行 yarn setup
if [ ! -z "$has_change" ]; then
yarn setup
fi

# 如果之前有 shelve,需要恢复 shelve
if [ -z "$no_shelve" ]; then
hg unshelve
fi
}

这个方法可以很好地工作,它可以满足:

  • 拉取代码时自动储藏、恢复本地改动
  • 当两端都同时修改 project-config 时,可以 update 或者 rebase 后再统一 yarn setup

后来发现使用 changegroup hook 配合 hg log 一样可以解决问题,于是就有了 changegroup.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

# 改动文件
regex='\bproject-config'

# 获取本次变更集的改动文件列表,判断是否有改动相关文件
# @see:https://stackoverflow.com/questions/3277334/what-files-will-be-changed-vs-added-when-i-do-an-hg-pull-and-hg-update
has_change=$(hg log -v -r $Hg_NODE: | grep "files:" | grep -E "$regex")

# 有改动相关文件,需要执行 yarn setup
if [ ! -z "$has_change" ]; then
cd $(hg root) || exit 1
yarn setup
cd -
fi

因此 hgpl 可以精简成这样:

1
2
3
4
5
6
7
8
9
10
hgpl() {
has_shelve=$(hg shelve | grep "nothing changed")

hg pull --rebase

# 如果之前有 shelve,需要恢复 shelve
if [ -z "$has_shelve" ]; then
hg unshelve
fi
}

commit message 检查(pretxncommit)

使用 pretxncommit 钩子可对当前提交信息进行检查,如检查 commit message:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

if [ "$SKIP_LINT" = "1" ]; then
exit 0
fi

shelve_user="shelve@localhost"

commit_user=$(hg tip --template {user})

# 因为 hg shelve 也会触发 pretxncommit 钩子,所以要进行忽略
if [ "$commit_user" == "$shelve_user" ]; then
exit 0
fi

commit_message=$(hg tip --template {desc})

echo "[msg] $commit_message"

echo "$commit_message" | ./node_modules/.bin/commitlint

铁器时代?

这个标题之所以打上一个问号,是因为该方案仍在 POC 阶段,尚未落地实施,但也可作为一个对未来的展望。

迄今为止,我们的 Hg hooks 已经能够满足大部分场景了,那还存在些什么问题呢?

相信不少同学已经发现这样操作会存在有一个很明显的问题,那就是:hooks 配置如何同步?

我们知道 .hg 目录是不会加入版本控制的,这是非常合理且必要的,因为 hooks 本身是一些权限极高的可执行脚本,所以出于安全考虑(你也不想你 clone 一个仓库后,它会自动执行某些你不想执行的命令),因此是不会有任何一个 VSC 会将 hooks 加入版本控制的。

可是这就会导致:

  1. 假如项目新增了一个 hook,需要通知项目成员同步修改本地的 hooks 配置。
  2. 新成员加入项目,需要手动配置 hooks。

如果这个问题不能得到解决,那归根到底还是无法绕过通知项目成员手动操作的过程。

所幸,以上问题在 Git 中同样存在,并且已经有很多非常成熟的方案,如: huksypre-commit

那有没有人在 Hg 生态上解决这个问题呢?粗略找到了两个:

  1. husky-hg
  2. tdog-husky-hg(前者的 fork

都是基于 husky v0.14.3 改造的,最后提交时间都在三年前(2019),然而 husky 现在已经迭代到 v7 版本了,这 3 年间经过无数迭代,使用方式和实现原理都发生翻天覆地的变化,于是我们决定基于 husky v7 自行改造。

但是在此之前,我们先了解一下 Git 如何配置 hooks:

  1. 在以前的 Git 版本中,如果要配置一个 hooks 则需要在 .git/hooks 目录新增一个 hook 同名的可执行文件,并且出于前面说的安全考虑, .git 目录是不会被加入版本控制的,因此也存在上面所说的问题
  2. 在 Git v2.9 以后,支持通过配置 core.hooksPath 自定义项目的 hooks 的存放路径,也即意味着可将 hooks 加入版本控制,项目成员只需要在第一次配置 core.hooksPath 即可,后续增删 hooks 都可直接使用。

因为 Git 支持 core.hooksPath ,所以 husky 直接采用了新的实现原理重构:

  1. 在 huksy v4 的时候,由于 Git hooks 目录无法被加入版本控制,它们是这样解决这个问题的:

    1. 在初始化的时候就在 .git/hooks 目录预先创建所有的 hooks 可执行文件,然后在 hooks 文件中执行定义在 package.json 中的 hooks 命令。
    2. 这样很显然可以解决 hooks 无法同步的问题,但是这个实现原理也被不少人诟病,见 #260
  2. 由于 Git v2.9 的升级,在 husky v7 中使用了新的实现方式:

    1. 将 hooks 可执行文件存放在一个可以被进行版本控制的目录(默认是 .husky),然后初始化的时候只需要配置 core.hooksPath 即可。

显然,v7 的实现方式更加方便快捷了,除此之外,它们的使用方式也有很大的不同:

  1. 在 v4 中,通过在 package.json 中配置 husky 字段来定义 hooks。
  2. 在 v7 中,它不再仅限于 Node.js 项目,可以直接通过 CLI 的方式进行配置,参见:Why husky has dropped conventional JS config

在深入了解背后的实现原理后,我们得出了结论:

  1. v4 版本的代码有较多历史包袱,不利于改造,故基于 v7 版本修改
  2. 但 v7 版本的实现方式对 Hg 并不完全适用,所以需要继续沿用 v4 的部分实现方式,所以这样设计:
    1. 将 hooks 脚本存放在可被版本控制的 .husky 目录
    2. 但不通过预注册所有的 hooks 的方式,而是采用按需配置,初始化时根据 .husky 的 hooks 可执行文件列表注入 hooks 配置。
      1. 比如在 Node.js 项目中可以通过 npm 的 prepare 钩子来自动初始化。
  3. 因此,使用方式与 husky 文档 中基本一致。
    1. husky installhusky add .husky/pre-commit

以上的心路历程、改造进展可以通过这个 PR 查看,感兴趣的同学可自行尝试:

  1. clone 项目,安装依赖,执行 npm link。
  2. 参考 husky 文档 进行使用。

背后的一些二三事

最后分享一些我们在实践 Hg hooks 时的小插曲。

一个隐藏字符引发的前端事故

有一天下午,在群里收到这么一个反馈:

2471647676117_.pic

点开大图一看,好家伙!赫然一个「口」字就这么明目张胆地贴在页面的左下角,看它「浓眉大眼」的。

到底是哪里出了问题呢?

仔细看清楚,才发现它其实不是一个「口」字,而是「□」,学名叫做 虚缺号,通俗地讲就是一个特殊字符。

于是打开对应的代码文件,果然一个红底白色 BS 字符引入眼帘:

这是 VSCode 的锅?

在网上有一番搜寻后,发现早就已经有不少人遇到过这个问题:

看下他们提供的复现过程:

hmmm

直接说下这个 Bug 的结论:

  1. VSCode 开启 webview 的情况下,使用中文输入法时按下退格键,就会导致出现退格符。
  2. VSCode 底层是 Electron,Electron 底层用的 chromium,这个 BUG 是 chromium 的。
  3. 该 BUG 已经在 VSCode v1.4.0 得到修复,参见这个 issue

但既然该问题在 2019 年已经修复,那为什么在 2022 年的今天还会出现这个退格符呢?

由于已经复现不了,根源追求也就只能不了了之,但影响又如此之大,所以我们应该怎么去规避它呢?

规避方案

利用 VSCode 扩展自动删除

有一个 VSCode 扩展 Remove backspace control character 专门用于解决此类问题,安装后我们只需要在 setting.json 添加如下配置:

1
2
3
4
"[wxml]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "satokaz.vscode-bs-ctrlchar-remover"
}

即可在保存代码的时候自动移除这些特殊的隐藏字符。

实现方式主要是通过正则去匹配这类隐藏字符:

1
/[\u0000]|[\u0001]|[\u0002]|[\u0003]|[\u0004]|[\u0005]|[\u0006]|[\u0007]|[\u0008]|[\u000b]|[\u000c]|[\u000d]|[\u000e]|[\u000f]|[\u0010]|[\u0011]|[\u0012]|[\u0013]|[\u0014]|[\u0015]|[\u0016]|[\u0017]|[\u0018]|[\u0019]|[\u001a]|[\u001b]|[\u001c]|[\u001d]|[\u001e]|[\u001f]|[\u001c]|[\u007f]/gm

在这里查看所有字符的介绍:Unicode,本文所出现的 BS 正是 [\u0008],也就是退格符。

提交代码前自动删除

更好的方式是:我们可以在 precommit 钩子自动做这件事:

1
2
3
4
find . -name "*.wxml" -exec perl -i -p -e "s/[\x08]//g" {} +

# 这行命令的 time total
0.10s user 0.80s system 93% cpu 0.953 total

让 vscode-hg 提交代码时显示 ESLint 报错的规则

起因是某位同学反映在 vscode-hg 提交代码的时候,无法显示 ESLint 校验不通过的规则提示:

image-20220320141520784

通常我们提交代码时,如果 yarn lint 不通过,会输出如下:

1
2
3
4
5
6
7
8
9
10
$ eslint '**/*.js' --cache --fix

/Users/4ark/project/helper/404.js
9:7 error 'a' is assigned a value but never used no-unused-vars

✖ 1 problem (1 error, 0 warnings)

error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
abort: pre-commit hook exited with status 1

而 vscode-hg 只输出如下:

1
2
error Command failed with exit code 1.
abort: pre-commit hook exited with status 1

不过经测试在 VSCode 中进行 Git 代码提交时并不存在该问题,所以猜测是 vscode-hg 这个扩展的原因。

于是抱着怀疑的态度看一下源码,发现果然如此:

1
2
3
4
// src/hg.ts#L620
if (options.logErrors !== false && result.stderr) {
this.log(`${result.stderr}\n`);
}

这里只输出了 stderr,但是 ESLint 的规则输出是 stdout。

于是我们为了更好地使用 Hg hooks,让它支持了输出 ESLint 规则,见 #185

结语

以上就是我们在实践 Hg hooks 过程的一些经历和心得,未必是最佳解决方案,正如文本开头所说,撰写本文的目的是希望能起到抛砖引玉的作用,与大家一起进一步的深入探讨。

对于本文的实践思路、代码实现有任何的意见和建议,都请不吝指教。

最后感谢大家的阅读。

周刊第4期:独立思考

本周做了啥

给日常使用的 vscode-hg 扩展提了两个没什么技术含量的 PR,顺便蹭了一个 contributor:

分享文章

一些本周阅读过的好文章、以及我的一些总结和个人思考;非常建议你直接阅读原文,毕竟一千个读者就有一千个哈姆雷特,而且我的理解可能是错的。

useMemo 和 useCallback 之间的深入比较

原文地址:《A Deep Dive Comparison Between useMemo And useCallback》 | Technical Blog

  • 它们的目的:都是通过缓存提高性能,避免组件重复渲染
  • 相似之处:
    • 用法一致:与所有 hooks 一样,只能在组件的顶层调用
    • 接收的参数一致:第一个为函数,第二个为依赖项
    • 功能一致:返回缓存过的值,检测到依赖性发生时重新计算并缓存
  • 区别:
    • 它们表面上没有真正的区别
    • 它们的内部实现也基本一致
    • 使用场景的区别:useCallback 缓存函数,而 useMemo 缓存其它类型
    • 实现原理的区别:useCallback 是缓存函数本身,而 useMemo 是缓存函数的返回值。

以下是它们的实现方式:

  • useCallback
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}
  • useMemo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}

初级开发人员如何为新团队提供价值

原文地址:《How You As a Junior Developer Can Immediately Provide Value When Joining a Team》 | Technical Blog

  • 初级开发人员并不意味着不能提供价值:相反,初级开发人员可以给团队带来很多东西。
  • 质疑现状:如果一个团队长期合作,人们会开始接受彼此的缺点,习惯于某种风格和工作方式,也就会陷入舒适区,这可能会使团队停滞不前。所以你需要保持批判性,寻找团队懒惰、坏习惯、效率低下或者可以改进的地方,并且付诸行动去改变现状。
  • 新鲜血液:任何一个项目组都不可能每时每刻使用最新的技术、工具,可能会使得团队成员没有动力跟上最先进的技术,陷入一种恶性循环之中。你作为一个团队的新成员,可能你反而会在当前领域了解更多最先进的技术和工具,从而打破团队的恶性循环。
  • 意识到团队沟通的问题:长期合作的团队可能面临习惯现有的沟通方式,会自动忽略对方的缺陷,导致缺乏真正的沟通。而你能够意识到这些问题,并帮助团队提高这方面的效率。
  • 挑战团队现有的知识:每个团队内部都有一些的解决方案或流程,并且习惯于此,而你可以提出更多潜在的替代方案,帮助团队寻找更优的解决方案。

个人思考

作为一名初级开发人员可能认为自己只能负责最简单的一部分业务,并没有意识到自己能给团队带来这么多价值,但其实你可以通过你作为一个新成员的位置,去发现你刚加入的团队一些不好的习惯,通过正确的心态和行动去改变现状。

你的代码不必完美无缺

原文地址:《Your Code Doesn’t Have to Be Perfect》 | Technical Blog

作者通过一段经历,讲述他在实现某个功能时,由于想要实现最佳的解决方案,一开始就花费了大量的时间去进行完美的设计、抽象和封装,结果一个星期的时间没有任何真正的业务产出。

以下是作者一些教训:

  • 不要从开始就重构:不需要刚开始就寻找最优的实现方式,这会让你过早地陷入到某个细节当中,把大量的时间花费在无意义的抽象中。
  • 复制粘贴还不错:我们应该坚持 DRY(不要重复你自己),但这不应该是起点,而是最终目标。可以在刚开始时通过复制粘贴实现功能,但这不意味着它就是最终上线的代码,而是在这个功能工作之后,再通过重构来提高代码质量。
  • 真正的重构需要适当的知识:通过改进现有的代码会使你的重构过程更加高效,因为这时候你已经掌握了更多的信息,可以更好地了解哪些代码是有意义的抽象。

个人思考

每个开发者都经历过这个阶段,想要一开始就设计好所有的细节、编写最完美的代码,但这是不可能的,所有代码都是经过不断地重构。你的代码不必在一开始就完美无缺,在生产项目中更是如此,毕竟不存在没有 deadline 的项目,只要懂得这个道理,你的工作效率会大大提高。

关于编写可读代码的最重要的事情

原文地址:《The Most Important Thing I Learned About Writing Readable Code》

编写代码时最重要的是可读性,一段难以理解的代码,即使你已经知道它的目的,你也很难理解它。所以编写具有可读性的代码是非常必要的。

已经有非常多的经典书籍在探讨这个话题,例如:

  • 《代码大全》
  • 《重构》
  • 《代码整洁之道》

本文作者之前也写过几篇关于代码可读性的文章,不过我认为大部分已经是老生常谈了:

  • 《如何编写更具可读性的 React 代码》
    • 代码长度:更短的代码通常更容易阅读,但有时候并非如此。所以要根据场景,代码并不是越短越好。
    • 代码分组:将特定上下文的代码组合在一起,使得阅读性更高。React 的自定义组件、Hook 就是做这件事情。
    • 复杂的 JavaScript 结构:不是所有人都完全熟悉 JavaScript 的语言特性,如果依赖某些特性的固定或隐式行为,会使某些对 JavaScript 不太熟悉的开发人员难以理解这些代码。作者还特地拿 Array.reduce 来举了个例子,认为使用 Array.reduce 虽然可以让代码更加紧凑,但内部需要跟踪太多细节,如果直接使用 for-loop 会使代码更具有可读性。
    • 条件运算符 &&:这种短路的隐式行为没有 if-else 的可读性高。
    • 一次处理多种情况:例如在同一个 useEffect 处理多个 deps,会使代码更加混乱。
    • 变量命名:计算机两大难题之一,这个命题有点大。
  • 《6 个技巧使你的 PR 更容易被 Review》
    • PR 的用途:Why、How
    • 分享视觉变化的屏幕截图:根据代码变更很难想象视觉的变化,所以展示一个截图可以帮助 Reviewer 更快地知道界面变化。
    • 列出功能要求:列出你想要实现的功能预期,否则很难通过代码上下文去预测你的实现是否正确、或者还有更优的解决方案。
    • 列出新的依赖:如果新增了依赖,你是如何决定采用哪一个库的。
    • 避免复杂的代码实现
    • 提供有关如何 Review 的其他说明:告诉 Reviewer 从哪里开始 Review。
  • 《帮助你对 React 代码进行 Review 的 10 个问题》
    • 代码是否正常工作?
    • 我明白了发生了什么吗?
    • 代码是否可读?
    • 组件或 Hook 是否做得太多?
    • 这必须是组件或者 Hook 吗?
    • 这个 API 设计可以简化吗?
    • 有测试吗?
    • 测试有意义吗?
    • 这个功能的辅助功能方面如何?
    • 是否更新了相应的文档?

但作者认为有一件更重要的事情被忽略了,那就是:沟通。

每个人对于「代码是否具有可读性」的理解都不一样,所以日常中经常会出现下面这种对话:

  • “你觉得这段代码非常难以阅读,但我认为它很容易。”
  • “我不同意,我经常使用这种实现方式,但理解它并没有难度。”
  • “使用这种方式实现,而不是你提供的那种方式实现,意味着我们不需要担心 xxx,可以使代码更短。”

这种回答并非完全没有道理的,但它们都有共同点:他们之所以不同意使用这种实现会使代码可读性更差,是因为他们觉得自己能够理解这样的代码。

的确,他们确实非常熟悉这段代码是如何工作的,但他们搞错了一件事:他们认为我是因为理解不了这段代码,才觉得这段代码难以阅读。

然而事实并非如此,因为问题的根本在于:代码的可读性与你无关,而是与其他人有关,准确地说,是与未来接手这段代码的人有关,甚至这个人很可能就是六个月后的自己。

所以,你要为他们编写具有可读性的代码。

个人思考

首先我需要说明,我并不认同作者提到的「 for-loop 可读性比 Array.reduce 好」这个结论,我认为 Array.reduceforEachmap 这些标准方法并无不同,不是 JavaScript 的糟粕,甚至是精华部分;另外 Array.reduce 真正需要考虑的细节也不多,只要熟悉递归思想,它其实很好理解。

除此之外的大部分观点我都是非常认同的,特别是本文讲到的「沟通」二字。我曾待过一个团队,当时合并代码前是需要两人交叉 Review 的,也遇到过几次关于「这样实现的可读性好不好」的问题展开讨论,基本都是各执一词,往往这种时候都需要一个第三者来进行判断,由这个人决定采用哪一种实现。

还记得有一次更离谱,某位同学酷爱使用位运算符,他对此给出的理由是:这样实现会使代码更快。

首先我并不认同这种说法,因为他没有给出专业的对比分析,即便这是真的,但在我们负责的这种 Web 项目中,这种速度的提升简直是可以忽略不计的,所以我就「可读性」本身这件事与他讨论,结果他开始和我解释这个位运算符是如何工作的,这位同学就犯了上面提到的问题,其实我不是不理解位运算符如何工作,我还曾写过一篇《深入理解按位操作符》的文章,我只是单纯认为不应该在项目中使用位运算符罢了。

有趣的链接

  • 爱思想:华语圈内最具原创性和思想性的公益纯学术网站,有人文社科各领域、人品和作品均有一定高度的学者的大量访谈记录、论文等,可帮助你提高独立思考的能力。
  • 全历史:在全历史 App 或网站里,你可以按照时间轴、关系图谱、时间地图查看各国,各个历史时期的历史相关内容。
❌