阅读视图

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

再见 Hexo,你好 Astro!

引言

从 Hexo 到 Jekyll,再到 VuePress,最终又回到 Hexo,我一度以为这是我最后的归宿。然而,Astro 的出现,让我沉寂的心又悸动起来。

最近,Astro 可谓是火遍了整个博客圈。眼看着众多博主纷纷从 Hexo 或者其他平台迁移到 Astro,我原本并未放在心上。直到我怀着好奇点开了 Astro 的主题页面,映入眼帘的 AstroPaper 主题令我惊叹不已。它竟然如此丝滑流畅,再点开 Astro 介绍页一看,OK!这就立刻迁移到 Astro 平台。

迁移过程

迁移过程还算顺利,但为了满足个人需要,还是下了一些功夫,这里记录一下。

兼容旧链接

由于 Astro 的页面路径与 Hexo 不同,我需要做一些兼容处理,保证旧链接依然能够正常访问。

以前在 Hexo 中,我可以通过 permalink 字段自定义文章路径。然而,Astro 的文章路径是由 md 文件名决定的。例如,我之前一篇名为 “2023 年度总结” 的文章,其 permalink 设置为 post/2023-summary.html,但在 Astro 中,它的路径变为 posts/2024-01-01-2023-summary。

不过好在 Astro 处理起来并不复杂。只需创建一个名为 post/[slug]/index.astro 的页面,并在此页面中处理链接,将其与 md 文件中的 permalink 字段进行映射即可。

首先修改 src/content/config.ts,让它读取 md 文件的 permalink 字段:

const blog = defineCollection({
  type: "content",
  schema: ({ image }) =>
    z.object({
      ...
      permalink: z.string().optional(), // [!code ++]
    }),
});

然后在 src/pages/post/[slug]/index.astro 处理 permalink 的匹配即可,由于代码过长这里就不贴了,有兴趣点这里查看:src/pages/post/[slug]/index.astro

文章目录

AstroPaper 主题本身不具备 TOC 功能,为了方便阅读,我参考 Astro Cactus 主题,添加了一个 TOC 组件,方便阅读,看本篇文章右侧效果 👉

Disqus 评论组件

AstroPaper 主题也没有评论组件,我参考这篇文章,添加了 Disqus 评论组件。

代码高亮主题切换

Astro 使用 Shiki 作为代码高亮器,原生支持浅色和深色两种主题。然而,当我配置完成后,却发现切换到深色模式时代码高亮主题并未生效。经过排查,我发现 Astro 是直接将样式写到 code 标签的 style 属性中,而切换深色模式时并不会改变这些样式。因此,需要在 base.css 文件中手动覆写样式才能实现代码高亮主题的切换。

html[data-theme="dark"] .astro-code,
html[data-theme="dark"] .astro-code span {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg) !important;
  /* Optional, if you also want font styles */
  font-style: var(--shiki-dark-font-style) !important;
  font-weight: var(--shiki-dark-font-weight) !important;
  text-decoration: var(--shiki-dark-text-decoration) !important;
}

此外,Shiki 还支持 transformers 样式(就是上面那种 diff 样式),需要额外配置样式才能生效。有兴趣参考 src/styles/base.css,这里不赘述。

RSS

Astro 自带 RSS 插件,用就完事了。默认情况下,是不带全文的,如需全文输出,可使用 sanitize-html 和 markdown-it 进行解析和输出,具体方法可参考官方文档。

此外,AstroPaper 主题只有 /rss.xml 这种路径,而我之前的博客同时支持 /rss.xml 和 /atom.xml 两种路径。为了避免更新博客后订阅者(如果有的话..)无法正常收到文章更新,我将 rss.xml.ts 文件复制一份并命名为 atom.xml.ts。如果需要支持其他路径,同样方式处理即可。

整体感受

以前对 Hexo 主题的博客也做了不少定制化的工作,但改起来总觉得有点别扭的,可能是因为 ejs 或者 swig 引擎的缘故,Astro 使用类似 Vue 的语法,并提供了 Next.js 一样的路由和构建方式,让开发更加自然流畅。

而在用户体验上,得益于 View Transitions API 的加持,使切换页面更加流畅,无缝衔接。

总之,的确不错。如果你也喜欢折腾,那就快来试试 Astro 吧!

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 作者向你展示创建编程语言的乐趣。

最喜欢的音乐

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

  • 《梵高先生》

<iframe src="https://open.spotify.com/embed/track/572mdcKFCEMVgtSELmoIuU?utm_source=generator" width="40%" height="80" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"></iframe>

  • 《处处吻》

<iframe src="https://open.spotify.com/embed/track/7scQnPn4YQGV8ZgtDNcdu5?utm_source=generator" width="40%" height="80" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"></iframe>

  • 《可惜我是水瓶座》

<iframe src="https://open.spotify.com/embed/track/1sWUT5hSawhEctQEQrVQdn?utm_source=generator" width="40%" height="80" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"></iframe>

2023?

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

想要实现的东西:

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

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

我们如何从 Wxml2Canvas 迁移到 Painter

路漫漫其修远兮

糖纸苦 Wxml2Canvas 久矣!

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

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

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

<img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202212270056681.png?imageMogr2/format/webp" alt="image-20221227005600071" style="zoom: 50%;" />

<img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202212270056939.png?imageMogr2/format/webp" alt="image-20221227005620310" style="zoom:50%;" />

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

吾将上下而求索

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

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

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

大致流程如下:

<img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202212272228830.png?imageMogr2/format/webp" alt="image-20221227222820467" style="zoom:50%;" />

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

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

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

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 方法的职责就是根据需要绘制的节点类型进行格式转换:

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 的工作将不再是个梦。

山重水复疑无路

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

果不其然,让全网「沸腾」的鸿蒙首当其冲,如下图所示: <img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202212280057359.png?imageMogr2/format/webp" alt="image-20221228005732730" style="zoom: 20%; text-align: left; margin:0;" />

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

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

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

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

<img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202212282240876.png?imageMogr2/format/webp" alt="image-20221228223957183" style="zoom:50%;" />

翻看 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 生成同一张分享海报,对比两张图片的不同,结果发现导出的图片无论尺寸还是文件大小都是一模一样的,如图所示:

柳暗花明又一村

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

<img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202212291328173.png?imageMogr2/format/webp" alt="image-20221229132803803" style="zoom:50%;" />

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

经过这一番操作,鸿蒙和 iPhone 12 也终于服帖了。然而,又有新的问题出现了。当某个页面生成并保存图片后,在滑动该页面时会明显感觉卡顿,对比一下 fps(帧率)的变化,确实离谱。 <img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202301032334581.png?imageMogr2/format/webp" alt="image-20230103233431180" style="zoom:100%;" />

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

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

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

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

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

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

<img src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202212300914569.png?imageMogr2/format/webp" style="zoom:50%;" />

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

关于二舅的一些感悟

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

从一次 yarn 命令死循环说起

前言

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

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

遇到什么问题呢

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

monorepo
├── package.json
├── app-a
│   └── package.json
├── app-b
│   └── package.json
└── config
    └── package.json

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

"dependencies": {
  "@monorepo/config": "../config",
}

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

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

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

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

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

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

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 掉这些进程:

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

分析原因

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

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

ps -ef | pbcopy

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

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

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

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

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

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

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

发现 pwd 输出是这样子的:

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

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

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

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

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

/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,那我们就从这个版本的源码来进行分析,首先克隆代码到本地:

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

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

yarn && yarn watch

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

"bin": {
  "yarn": "./bin/yarn.js",
  "yarnpkg": "./bin/yarn.js"
},

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

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

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

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

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 命令一致,通常是这么使用的:

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

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

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

> 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 中:

git rev-parse --show-toplevel

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

- 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 中,你可以通过以下代码来获取用户输入:

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:后摄像头
<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的介绍。

有趣的链接

  • 巴别英语 - 英语听力口语在线学习:刷TED演讲和美剧佳作练听力, 带单句步进重复、灵活中英字幕设置等专为英语学习设计的功能, 让学英语变得容易、有趣、高效。

  • Free Word Cloud Generator:构建你的词云,按相关性和频率对结果进行排序,探索更高级的文本分析工具。

  • JS NICE:一个 JavaScript 反混淆的在线工具。

  • Slashy:一个可以创建 Notion 自定义命令的增强工具,非常不错。

  • Type Scale:一个可视化的字体大小调节工具,可以预览 CSS 字体在不同 rem 上表现。

  • ffmpeg-buddy:一个 ffmpeg 参数生成工具。

  • NGINX 配置:一个 nginx 配置生成工具。

  • 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 src="https://gd4ark-1258805822.cos.ap-guangzhou.myqcloud.com/images202205312247639.jpeg" alt="img"/>

Edge 的 AI 图像增强功能

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

附上对比效果:

冒名顶替综合症

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

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

一些 tips

没有 Docker Desktop 的情况下运行 Docker

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

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

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

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

> brew unlink colima # 已经安装的话,先卸载

> brew install --HEAD colima

> colima start --runtime docker

> docker ps # 成功

分享文章

主要版本号不是神圣的

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

版本号递增规则如下:

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

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

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

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

有趣的链接

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

需求

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

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

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

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

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

# .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 用法,首先要安装这个工具,可以使用全局安装,但一般更推荐在项目本地安装:

npm install husky -D

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

{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "pre-push": "yarn test"
    }
  }
}

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

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

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

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

让我们开始吧!

实践

v4 以前的版本

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

{
  "husky": {
    "hooks": {
      "pre-commit": "echo hello husky!"
    }
  }
}

然后读取 package.json

// 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 可执行文件:

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

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

执行一下:

node husky.js

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

> cat pre-commit

#!/bin/sh
echo hello husky!

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

> git commit -m "test"

hello husky!

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

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

{
  "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 后执行。

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

.
├── husky-test
│   ├── husky
│   │   ├── husky.js
│   │   └── package.json
│   └── package.json

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

{
  "name": "husky",
  "version": "1.0.0",
  "license": "MIT",
  "scripts": {
    "install": "node husky"
  }
}

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

{
  "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 时要中断本次操作。

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

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

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

const install = require('./installer')

install()

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

// 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

#!/bin/sh

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

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

# sh/husky.sh
gitParams="$*"
hookName="$(basename "$0")"

npm husky-run $hookName "$gitParams"

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

// husky/package.json
{
  "name": "husky",
  "version": "1.0.0",
  "license": "MIT",
  "bin": {
+   "husky-run": "./runner/index.js"
  },
  "scripts": {
    "install": "node husky"
  }
}

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

#!/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 添加如下配置:

{
  "name": "husky-test",
  "version": "1.0.0",
  "license": "MIT",
  "dependencies": {
    "husky": "./husky"
  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "echo 123 && exit 1"
+    }
  }
}

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

> 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 使用方式介绍一遍:

# 初始化
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 的入口:

// 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 都将存放在此目录。
// 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 的配置即可:

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

set 添加 hook

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

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 文件追加脚本文件,

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.net 17 17
alpha.mike-r.com 17 17
cygnus-x.net 17 17

可以用它做什么呢?

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

分享文章

Bash Pitfalls: 编程易犯的错误

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

Docker 镜像构建的一些技巧

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

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

  1. 删除缓存

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

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

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

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 命令,其实也可以。

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

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

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

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

const comments = commentStrs
    .filter(noNazi)
    .slice(0, 10)
    .map(emphasize)
    .map(itemize)
    .join('\n');

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

const comments = pipe(commentStrs,
    filter(noNazi),
    take(10),
    map(emphasize),
    map(itemize),
    join('\n'),
);

这样写的好处在哪呢?

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

const comments = pipe(commentStrs,
    filter(noNazi),
    take(10),
    map(emphasize),
    map(itemize),
    join('\n'),
+   chaoticListify,
);

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

const map = f => function*(iterable) {
  for (let x of iterable) yield f(x);
};

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

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

中国黑客关系图

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

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

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

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

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

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

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

有趣的链接

  • zonemeen/musicn:🎵 一个下载高质量音乐的命令行工具

  • Similarweb:查看并分析任何网站流量,站长必备工具

  • Codeit:手机连接 Git 查看代码的神器 APP

  • Queue:使用 Notion 发布 Twitter 的工具

  • Coverview:生成文章题图的工具

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

需求

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

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

实践

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

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

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

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

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

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

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

使用 yarn 安装:

{
  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 安装:

{
  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 的解决方案:

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. 这段代码的健壮性如何?

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

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

然后改为通过接收参数:

+ 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. 如果以后有新需求:除了要限制包管理器,还要限制到具体某个版本怎么办?

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

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 的代码抽离出来,并使其可以获取版本,以便后续扩展:

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

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

  return {
    name,
    version
  }
}

完整代码:

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 年就有人提出,但目前仍未有解决方案出现。

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

❌