阅读视图

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

2024,我使用的工具从 A 到 Z

写在前面

今年有一次,同事突然问我,可有什么好工具推荐。我一时竟怔住,不知如何作答。倒也不是没有,平日里确实积累了不少趁手的工具,只是仓促间让我说出来,倒也为难。

只因每种工具有其特定的适用场景,而每个人偏好亦有不同。正所谓:我之蜜糖,彼之砒霜。

事后想想,其实可以写一篇文章,把我用的工具都介绍一下。虽然数量不少,也不一定都适合每个人,但肯定有人能从中找到适合自己的。所以,我决定参考别人的方式,写一篇我自己的《从 A 到 Z》,把我 2024 年日常工作和生活中使用的各种工具,按照字母顺序一一列出来,和大家分享。

以下是一些工具的分类,包括 macOS、iOS、安卓 TV 等系统软件、各类网站、浏览器扩展以及命令行工具和 npm 包。为了方便查阅,我将首先按类型分类,再在各类中按字母顺序排列。

macOS 软件

APTV

吃饭时追电视的好帮手!支持苹果全家桶,虽不自带直播源,但配合自定义源简直绝了。

我个人维护了一套包含卫视频道、港台频道和网络直播的源,如有需要可邮件联系。

Applite

免费开源的 macOS 工具,专为 Homebrew 第三方应用的安装和管理设计,操作更直观,省时省力。

Bob

Bob 是一款强大的翻译和 OCR 工具,通过选择文本并按下快捷键 (Option + D) 即可快速翻译或进行 OCR 识别,配合 PopClip 甚至可以实现无键盘操作。更重要的是,Bob 支持自定义 AI 接口和提示语,实现高度个性化的翻译、润色、解释等功能。以下是我常用的几个提示语示例:

1. 翻译(使用 Pro 用户专属的「智谱翻译官」)

<details> <summary>点我展开提示语</summary>

出处:https://x.com/dotey/status/1727091267870367880

角色设定

你是一位精通 $query.detectToLang、$query.detectFromLang 的专业翻译,尤其擅长将专业学术论文翻译成浅显易懂的科普文章。请你帮我将以下 $query.detectFromLang 段落翻译成 $query.detectToLang,风格与 $query.detectToLang 科普读物相似。

规则:

- 翻译时要准确传达原文的事实和背景。
- 即使上意译也要保留原始段落格式,以及保留术语,例如 FLAC,JPEG 等。保留公司缩写,例如 Microsoft, Amazon, OpenAI 等。
- 人名不翻译
- 同时要保留引用的论文,例如 [20] 这样的引用。
- 对于 Figure 和 Table,翻译的同时保留原有格式,例如:“Figure 1: ”翻译为“图 1: ”,“Table 1: ”翻译为:“表 1: ”。
- 全角括号换成半角括号,并在左括号前面加半角空格,右括号后面加半角空格。
- 输入格式为 Markdown 格式,输出格式也必须保留原始 Markdown 格式
- 在翻译专业术语时,第一次出现时要在括号里面写上英文原文,例如:“生成式 AI (Generative AI)”,之后就能只写中文了。
- 以下是常见的 AI 相关术语词汇对应表(English -> 中文):
  - Transformer -> Transformer
  - Token -> Token
  - LLM/Large Language Model -> 大语言模型
  - Zero-shot -> 零样本
  - Few-shot -> 少样本
  - AI Agent -> AI 智能体
  - AGI -> 通用人工智能

策略:

分三步进行翻译工作,并打印每步的结果:

1. 根据内容直译,保持原有格式,不要遗漏任何信息
2. 根据第一步直译的结果,指出其中存在的具体问题,要准确描述,不宜笼统的表示,也不需要增加原文不存在的内容或格式,包括不仅限于:

- 不符合中文表达习惯,明确指出不符合的地方
- 语句不通顺,指出位置,不需要给出修改意见,意译时修复
- 晦涩难懂,不易理解,可以尝试给出解释

3. 根据第一步直译的结果和第二步指出的问题,重新进行意译,保证内容的原意的基础上,使其更易于理解,更符合中文的表达习惯,同时保持原有的格式不变

返回格式如下,"{xxx}"表示占位符:

意译

{意译结果}

————

直译

{直译结果}

————

问题
{直译的具体问题列表}

用户指令:

现在请按照上面的要求从第一行开始翻译以下内容为 $query.detectToLang:

$query.text

</details>

  1. 润色(使用 bob-plugin-openai-polisher

<details> <summary>点我展开提示语</summary>

请润色下面的文章,使其更加简洁,同时确保所有原因和理由依然明确。
文章的语气应保持轻松随意,而不会过于正式。

</details>

  1. 通俗解释(使用 bob-plugin-openai-translator

<details> <summary>点我展开提示语</summary>

你是一个严谨的学者,但同时也希望用轻松有趣的方式分享知识。请将以下复杂文本用通俗易懂的口语解释出来。解释时可以适度加入一些幽默元素,例如巧妙的比喻、有趣的类比、轻松的语气等,但幽默的程度要适中,不要喧宾夺主,更不能影响对文本的准确理解。

</details>

Beyond Compare

虽然价格肉疼 ($49.9),但用过之后真香!Beyond Compare 是一款强大的文件和文件夹对比工具,支持各种文件类型,包括 PDF。对比目录下的文件差异效果极好,还能解决代码冲突。

最近出了 v5,但 v4 已经足够用了,不必升级。

Bitwarden

之前从来不用密码管理器,但入职这家公司后被强制要求,才发现它的好用之处。

我选了 Bitwarden,起初打算自建服务,但官方版本已经完全够用。

如果你还没用过密码管理器,强烈推荐开始用,安全又方便。

Charles

macOS 抓包必备神器,用过的都懂,不用多说。

ClashX Pro

和 Charles 类似的工具,如果不是用这个,应该也会用类似的东西。

Command X

macOS 原生不支持 Command + X 文件剪切,但装了它就可以,补齐功能缺失。

Cursor

VSCode 改造而来的 AI 代码编辑器,初体验还不错,但免费期过后价格略贵。

后来接入 deepseek API 用了一段时间,最近转投 Windsurf,体验更棒。

Dash

查文档利器,配合 Raycast 使用效率爆表。

DevUtils

开发者工具合集,小而全,配合 Raycast 更好用。

Fileball

现代化的多媒体播放器,我主要当 Emby 客户端用,跨设备看 Emby 影视非常方便。

FixTim

macOS 故障救星!Wi-Fi 连不上?iCloud 同步出错?别急着重启,试试它能不能修好。

Follow

刷屏社区的 RSS 阅读器,支持电脑端和网页端,体验极佳。

这里安利一下我的梗图 List:梗图列表,有兴趣可以订阅!

Function Key Pro

Fn 键增强工具,让 F1~F12 支持短按、长按和 Fn + 短按三种操作,功能自定义随心所欲。

比如双手腾不出来时,长按 F11 就能调低音量,非常实用。

Hidden Bar

开源的状态栏图标隐藏工具,让状态栏更简洁。

IINA

国产开源播放器,体验很棒,但我用得不多。

Itsycal

菜单栏日历工具,能直接查看日程安排,操作简单,颜值在线。

kitty

之前一直用 iTerm2 作为主力终端,但今年用着总觉得有点不顺手,于是试着折腾了几款新的终端工具。最后选中了 kitty,简单介绍下我的配置和体验。

快速呼出终端:

上学时,我用 Linux Mint 作为主力系统,特别喜欢一个叫 Tilda 的终端工具,它有个超酷的功能:不管你当前在哪个应用窗口,按下 F11 就能瞬间呼出终端窗口。后来切换到 macOS 后,这个习惯一直保留着,只不过工具换成了 iTerm2,现在是 kitty。

在 kitty 里,我用 Raycast 配合快捷键来呼出终端,按下 Option + F11,窗口立刻出现,而且占满屏幕的上半部分。虽然 kitty 不原生支持快捷键显示窗口,但这个搭配已经非常顺手了。

窗口尺寸配置:

initial_window_width 54c
initial_window_height 10c

快捷键适配:

为了保持之前用 iTerm2 的操作习惯,我也在 kitty 里折腾出了一些相似的快捷键。比如,按下 cmd + d 就能快速分割一个 panel。用起来几乎无缝衔接,效率直接拉满。

map cmd+1 goto_tab 1
map cmd+2 goto_tab 2
map cmd+3 goto_tab 3
map cmd+4 goto_tab 4
map cmd+5 goto_tab 5
map cmd+6 goto_tab 6
map cmd+7 goto_tab 7
map cmd+8 goto_tab 8
map cmd+9 goto_tab 9
map cmd+0 goto_tab 10

map cmd+enter toggle_fullscreen

enabled_layouts splits,stack

map cmd+d launch --cwd=current --location=vsplit
map cmd+shift+d launch --cwd=current --location=hsplit
map cmd+w close_window
map cmd+t new_tab_with_cwd

搜索功能:

kitty 不支持用 cmd + f 快捷键来搜索输出的内容。因此从网上找了两种方法实现搜索功能。

# fzf
map ctrl+f launch --type=overlay --stdin-source=@screen_scrollback /opt/homebrew/bin/fzf --no-sort --no-mouse --exact -i
# https://github.com/trygveaa/kitty-kitten-search
map cmd+f launch --allow-remote-control kitty +kitten kitty/kitty_search/search.py @active-kitty-window-id

KeyboardHolder

一款根据不同应用、不同网站、不同场景自动切换到对应的输入法的辅助工具。

Latest

可以帮你快速检查系统中有哪些软件需要更新。

LICEcap

开源的 GIF 屏幕录制小工具,用它就够了。

LocalSend

免费、开源、跨平台的文件分享工具。

Logseq

近几年,「第二大脑」这个概念非常火爆,简单来说,就是将要记住的信息存储在笔记工具里,把大脑留给思考和创造。

支持反向链接(Backlink)的笔记软件是最适合用作第二大脑的工具,你只需输入关键词,就能轻松回溯相关内容,拓展思维。

Logseq 就是这种类型的优秀代表。虽然它并不是所有人心目中的“最佳选择”,但它高度适配「输入 → 整理 → 回顾」的流程。

提醒:别陷入折腾软件的死循环,选一个顺手的工具,专注输入和输出才是正道!

Marta

macOS 自带的 Finder(访达)一向饱受诟病,功能简单又鸡肋。这时,Marta 就能解救你!它支持多栏操作,非常方便进行文件拷贝等操作。如果你想试试其他选择,QSpace 也是不错的替代方案。

Microsoft Edge Dev

这几年,我的主力浏览器一直是 Microsoft Edge,尤其是 Dev 版本。

尝试过其他浏览器(Arc)后,我还是回到了 Edge,毕竟熟悉的工具才是最顺手的。

Notion

虽然现在我的笔记已经迁移到 Logseq,但 Notion 强大的数据库功能依旧让我无法割舍。

  • 收藏管理:配合浏览器扩展 Save to Notion,轻松将文章和网站收集整理。
  • 数据存储:复杂的信息和资源管理,Notion 的数据库能力仍然无可替代。

OrbStack

如果你还在用 Docker Desktop 或 Colima,那么恭喜你发现了更好的选择:OrbStack!

  • 轻量快速:启动速度快,资源占用少。
  • 操作顺滑:整体体验比 Docker Desktop 流畅许多,绝对让你用过之后后悔没早一点发现它。

Parallels Desktop

macOS 上最好用的虚拟机软件,没有之一。

由于我的电脑只有可怜的 512GB,因此我选择将虚拟的 Windows 11 安装在移动硬盘上。

PicGo

一个图床上传工具。

Poe

这个平台集成了多种 AI 模型,价格相比官方渠道便宜不少。我自己没有订阅,只是偶尔用用每天免费的额度,感觉已经够用了。

RapidAPI

前身是 Paw,当初限免时下载的,现在一直在用。相比 Postman,它用起来顺手多了。

Raycast

相信不用我介绍了,但如果你真心不知道这款软件,那么我强烈推荐你使用。

安装 Raycast 后,你可以轻松告别一堆单独的工具,比如:

1. Alfred(更别提 Raycast 免费!)
2. 剪切板管理工具
3. Snippets 代码片段管理工具
4. 软件卸载工具
5. 窗口管理工具
6. 便签工具

Raycast 自带的 Store 拥有丰富的扩展,你可以根据需要找到最适合自己的工具。比如不少主流软件都提供了对应的 Raycast 插件。

借助 Quicklink 功能,你可以自定义命令,比如输入 v2 回车,直接打开 V2EX 摸鱼,效率拉满。

这里我推荐一些常用的扩展:

- Base64:快速解析/编码文本
- ChatGPT:一键召唤 GPT,随问随答
- Google Search:直接启动 Google 快速搜索
- Kill Process:快速查找并 kill 进程
- Microsoft Edge:搜索收藏夹与历史记录
- Search Commands:搜索并查看 Linux 命令
- Search HTTP Status Codes:查阅 HTTP 状态码的含义
- Search npm Packages:快速查找 npm 包信息
- Transform:文本/代码转换神器,例如 CSS 转 JavaScript Object
- Visual Studio Code:直接用 VSCode 打开项目文件

RIME

RIME(中文名“鼠须管”)是一款开源输入法,堪称「神级输入法」。如果你和我一样,想摆脱互联网公司输入法的捆绑,追求更高自由度的自定义配置,那么 RIME 是绝对值得一试的选择。

推荐配置:

  • rime-ice:基本开箱即用,功能强大。
  • 词库优化:rime-ice 自带的词库虽然够用,但不如搜狗全面,所以我找了个更强的词库配置

为什么选择 RIME?

1. 高度自定义:支持任意配置方案,可以根据个人输入习惯深度定制。
2. 纯净无广告:没有后台上传数据的烦恼,输入体验干净纯粹。
3. 多平台支持:macOS(鼠须管)、Windows(小狼毫)、Linux(中州韵)都能用。

Remote for Mac

可以在 iOS 上远程操作 macOS 的软件,有时候用它挺方便。

Scroll Reverser

让鼠标滚动方向变得更习惯,尤其是从 Windows 转到 macOS 的用户。

Skim

轻量级 PDF 阅读器,支持标注和笔记,非常适合看书或做研究。

Slack

团队协作的聊天工具,个人感觉比国内同类软件强太多。

Snipaste

从 Windows 开始就用的截图工具,简单快捷。

SnippetsLab

代码片段管理器,用于记录解决方案和命令行代码,配合 Raycast 非常高效。

Sourcetree

虽然更习惯用命令行管理 Git,但遇到复杂操作时,Sourcetree 简直是救星。

Spotify

用尼区车 42/年的价格听歌,值到飞起。

Tor Browser

你懂的。

算了,可能还有人不太懂。简单来说,就是个“洋葱浏览器”,主要用来访问暗网。但别一听“暗网”就觉得都是些见不得光的东西,事实上,暗网里也有很多正常内容。比如之前 z-lib 网站被 FBI 封了,那段时间,只有通过暗网才能继续访问。所以,你懂了吧?

TinyPNG4Mac

一款用于压缩 PNG 图片的工具。可能你不知道,它还有 macOS 客户端。优点是没有文件上传大小的限制,只要申请一个 API key,就能畅快使用,非常方便。

Tuxera Disk Manager

解决 macOS 不支持 NTFS 格式的问题,可以随意读写 NTFS 硬盘。

Typora

Markdown 编辑器,正式版收费,但用习惯了感觉值得。

Velja

双浏览器策略神器,专为解决个人与工作环境混乱而生。你可以根据 URL、应用 或 快捷键 设置不同的默认浏览器,轻松实现账号和插件的彻底隔离。

浏览器里个人和工作账号分不清楚,一直是个让我头疼的问题,尤其是不同的密码管理器插件在同一浏览器中互相冲突,简直让人崩溃。后来我果断采用了双浏览器策略,分别处理工作和个人的账号与插件。

Velja 正是这类需求的完美解决方案:

1. 匹配特定 URL:比如打开公司内部网站,就自动跳转到工作浏览器。
2. 快捷键支持:用 Fn 键加点击,就能快速选择浏览器。
3. 按应用区分:在 Slack 或邮件客户端中点击链接,也能智能地选择对应浏览器。
4. 浏览器插件:选择另外一个浏览器打开当前网页。

简而言之,所有工作相关的内容都会被引导到指定的浏览器,从此告别账号混乱,工作和个人完美隔离。

Visual Studio Code

无需介绍的代码编辑器。

Windows App

macOS 远程连接 RDP 桌面的工具,远程办公必备。

Zen Browser

今年朋友推荐了 Zen 浏览器,它流畅的运行速度和便捷的工作区功能尤其适合打开工作相关的网站。

因此它很自然地成为了我「双浏览器策略」中的选择之一。

iOS 软件

Alook

一块钱的 iOS 浏览器,却拥有强大的功能!无推送、无新闻、无广告,内置播放器还能悬浮播放网页视频,支持倍速和后台播放。强烈推荐!

Authenticator

2FA 验证码管理器,保护你的账户安全。微软、谷歌、LastPass 等都有类似的 APP,我用的是 LastPass 的,功能都差不多,选择你喜欢的就好。

Bark

让你可以把自定义消息推送到手机上的神器,M 系列 Mac 上也能用,玩起来很有意思。

彩云天气

最强天气预报 APP,当年送终身 VIP,但后来加了个 SSVIP 和 AI 小助手。虽说改版了,但天气数据和推送还是很靠谱。

Cape

用屏幕使用时间隐藏 APP,实用且隐秘。

锤子便签

老罗的经典产品,2024 年依然香,操作流畅,用着舒服。

DAMA

聊天打码必备,一键隐私保护,八爷的独立开发作品,简单又实用。

FIMO

复古滤镜相机,随便一拍就是大片。但如果像我一样没审美,效果可能也就那样。

HTTP Catcher

抓包工具,用来劫持请求、修改响应,研究技术或者调试时很好用。

Shortcuts

iOS 的效率天花板,用来自动化各种操作,解锁后你会发现手机更强了。

谜底时钟

充电时仪式感满满的时钟,设计感超棒,买了终身版没后悔。

nplayer

最强播放器,局域网远程播放、全格式支持,无需转码,无广告,一步到位。

Open Scanner

免费的文档扫描 APP,效果杠杠的,在 X 上被安利后就没换过。

Photo Cleaner

清理大图片、相似照片的利器,清理空间的好帮手。

Photomator

系统相册不够用?换它,替代效果一流,还支持 macOS。

Picsew

长截图神器,效果不错,操作方便。

PiP

让 Youtube 视频后台画中画播放(虽然 Alook 也能实现,但多一个选择总归好)。

Quantumult X

比小火箭省电还强大,今年买的最值的一款 APP,完全改变了我的使用习惯。

Reeder

看 RSS 的丝滑神器,手机端、电脑端都顶用,做 RSS 用户的福音。

什么时辰

查看时辰和放小组件的 APP,传统文化爱好者的心头好。

什么值得买

这个不用我多说了,剁手指南。

Stream

又一个抓包工具,简单粗暴。

Termius

功能全面的 SSH 客户端,远程管理必备。

Traffic Rider

无聊时解压的摩托车游戏,开一把放松心情。

Tubecasts

后台听 Youtube 的神器,支持倍速播放和定时关闭,睡觉前听视频专用。

VLLO

视频剪辑工具,操作简单,轻量用户的好帮手。

伟途亦可思

V2EX 的第三方客户端,摸鱼一绝。

小睡眠

助眠神器,睡觉听点白噪音或者轻音乐,秒入梦乡。

熊猫吃短信

超好用的短信过滤工具,熊猫 1 表现很稳,据说熊猫 2 改成订阅制后褒贬不一,所以我一直留在 1。

安卓 TV 软件

BBLL

第三方 bilibili 客户端,支持弹幕,界面清爽,个人感觉比官方版更适合 TV。

Emby

Emby 客户端的破解版,用来看 emby 服的视频,流畅好用。

Simple Live TV

专注看网络直播,涵盖各大网络平台的直播源,小巧实用。

Smartube

无广告的第三方 YouTube 客户端,体验比官方版还丝滑。

tvbox

必备的看剧神器,开源且可定制,各种魔改版本应有尽有。

推荐版本:takagen99,界面美观,使用舒适。

配置地址:http://www.饭太硬.top/tv/,配置完直接开爽。

天光云影

看直播源的软件,支持自定义源,直播体验优秀,操作方便。

一起看 TV

看剧专用,资源丰富、界面简洁,适合电视上轻松追剧。

网站

以下是一些实用的网站,配合 Raycast 的 quicklink 使用可以大幅提升效率。

aistudio.google.com

Google 的 AI Studio,探索和测试 AI 模型的平台,可以免费用最新的模型。

aktv.top

提供稳定的港澳台直播源。

bilin.ai

一个 AI 生成工具平台,用于图片、文本和代码生成。

chatgpt.com

开了 ChatGPT Plus 后一直在用,生产力提升神器。

copilot.microsoft.com

访问 Microsoft Copilot 的专属网站,体验 AI 助理的强大功能。

cs.github.com

快速从 GitHub 仓库中搜索代码,非常方便。

gemini.google.com

Google 的 AI Gemini 项目入口,专注于生成式 AI 的未来探索。

如果可以用 aistudio.google.com,就不需要这个了。

learning.google.com

Google 的在线学习平台,涵盖多种技术和技能的课程。

notebooklm.google.com

Google 推出的 AI 学习工具,用来整理和学习内容特别好用。

npm.runkit.com

在线运行 npm 包的沙盒环境,用于测试和演示代码。

npmgraph.js.org

用图表方式可视化 npm 包的依赖关系。

npmtrends.com

比较多个 npm 包的下载趋势和受欢迎程度。

perplexity.ai

一款结合搜索引擎和问答功能的 AI 工具。

pkg-graph.info

另一个 npm 包依赖可视化工具,帮助理解包的依赖结构。

promptperfect.jina.ai

AI 提示优化工具,生成和测试最佳的 AI 提示。

phind.com

专注于开发者的搜索引擎,直接返回技术相关的搜索结果。

search.luxirty.com

基于 Google 的搜索引擎,屏蔽内容农场,没有广告和跟踪,搜索体验清爽快速。

search.saveweb.org

搜索互联网存档,找回那些已经被删掉的网页内容。

thinkany.ai

AI 相关工具集合,可以探索不同场景下的 AI 应用。

v0.dev

一个实验性开发工具平台。

x.ai

grok AI 应用,现在面向所有用户免费开放。

浏览器扩展

AutoScroll

让 macOS 上也能用鼠标中键自动滚动网页,对 Windows 用户非常友好。

Bypass Paywalls Clean

绕过各种网站的付费墙,轻松访问隐藏内容。

Console Importer

在浏览器控制台一键 import npm 包,调试效率直线上升。

$i("jquery");

Don’t track me Google

屏蔽 Google 的隐私追踪,保护你的数据安全。

JSON Viewer

最强 JSON 查看器,格式化、折叠、查询一气呵成,开发者必备。

Kimi 浏览器助手

总结文章的好帮手,而且完全免费。

OneTab

一键保存所有标签页,整理工作或学习内容神器。

Save to Notion

一键把网页内容保存到 Notion,信息管理更高效。

SimpleExtManager

快速管理浏览器扩展,随用随开,不浪费资源。

SponsorBlock for YouTube

跳过 YouTube 视频的赞助片段,观看体验直接升级。

Stream Recorder

支持下载 HLS / m3u8 格式的直播视频,非常好用。

Velja

想用另一个浏览器打开当前网页?一键搞定。

Video Speed Controller

随意调节视频播放速度,倍速党福音。

Wayback Machine

快速保存网页到互联网档案馆,还能查看过去的版本。

uBlacklist

屏蔽 Google 搜索结果中的指定网站,专治乱七八糟的内容。

uBlock Origin

高效移除广告和跟踪器,浏览器清爽到飞起。

v2ex plus

优化 V2EX 的使用体验,增加各种实用小功能。

沉浸式翻译

深度翻译工具,强烈建议 Linux 用户配合 DeeplX API key 使用。

猫抓

网页上的媒体嗅探神器,各种音视频资源轻松抓取。

篡改猴

油猴脚本管理工具,扩展浏览器功能的不二之选。

过滤广告 為 Youtube™

纯粹为 YouTube 服务的广告屏蔽工具,专注且好用。

隐私獾

屏蔽广告和追踪器,还能让你手动选择是否加载一些网页组件。

命令行工具

我会在这里分享一些我常用的命令行工具和自己写的 alias,希望能提升你的终端使用效率。

ag

更好的 grep。

brs

配合 fzf 实现一键切换分支:

brs() {
 gbr | fzf-tmux | awk '{print $1}' | sed 's|origin/||' | xargs -I {} git switch {}
}

btop

更好的 top。

bat

更好的 cat

bclm

有个软件叫 AlDente,它可以帮你调整电池充电阈值。简单来说,当你的电池电量达到设定的数值后,软件会自动停止充电并开始使用电池供电。这样一来,你就不用手动插拔 MacBook 的充电器了。

这款软件是收费的,而 bclm 则是一个开源的命令行工具,可以达到和 AlDente 一样的效果。

使用它后,我的电池健康度一年才降了 5%!不过自从升级 Sequoia 后 macOS 不给写入 SMC 的权限了,所以 bclm 行不通了,现在使用 bclm_loop 代替。

bkp

快速备份文件

bkp() {
  cp -Rp "$1"{,.bak}
}

c

clear 的别名,更快清除屏幕。

cpf

我们都知道 pbcopy 可以复制输出的内容,比如 cat 1.txt | pbcopy。但有时候我们想要复制文件本身,而不是文件内容。这种情况下,通常需要先运行 open . 命令打开当前目录,然后手动拷贝文件。其实可以用纯命令行的方式来操作:

cpf() {
  osascript \
    -e 'on run args' \
    -e 'set the clipboard to POSIX file (first item of args)' \
    -e end \
    "$(pwd)/$@"
}

这样以后我们通过 cpf 1.txt 就能直接在微信等软件中粘贴并发送这个文件了。

difft

理解语法的 diff 工具,可配合 git 或者 hg 使用,让 diff 更好看。

可查看 git 小节查看使用例子。

edge-tts

顾名思义,一个使用 Microsoft Edge 文本转声音的工具,配合 Raycast script 可实现快速阅读一段文本,强烈推荐 xiaoxiao 人声。

fd

更好的 find。

fonttools

一个字体工具,用于生成字体子集或优化文件大小,从而达到瘦身效果,对于前端来说非常好用。

pyftsubset OPPOSans-H.ttf --text=$(cat 仅需要的文本.txt | rg -e '[\w\d]' -oN --no-filename|sort|uniq|tr -d '\n') --no-hinting

fuck

暴躁老哥的命令行工具,当你输错命令的时候,fuck 一下可帮你纠正。

fs

其实是 yazi 的别名,一个很好用的终端文件管理器。

fzf

如果你喜欢命令行操作,那么一定要了解这个工具!它可以让你对所有输出进行模糊搜索和自定义操作。

分享一下我的默认设置:

# 默认选项
export FZF_DEFAULT_OPTS="--height 100% \
--layout reverse \
--prompt '∷ ' \
--pointer ▶ \
--marker ⇒"

# 这行配置开启 ag 查找隐藏文件 及忽略 .git 文件
export FZF_DEFAULT_COMMAND='ag --hidden --ignore .git --ignore .hg -l -g ""'

fzf 在 tmux 下有个很酷的功能,可以让 tab 补全选项列表通过弹出窗体展示,且窗体会更紧凑:

if [[ -n "$TMUX" ]]; then
  zstyle ':fzf-tab:*' fzf-command ftb-tmux-popup
fi

此外,还可以使用 command ** + tab 键的组合来快速预览根目录(例如在执行 cd ** 时)或列出各个指令介绍(例如在执行 git ** 时)。

_fzf_complete_git() {
  _fzf_complete --bind "enter:become(echo {} | awk '{print \$1}')" -- "$@" < <(
    git --help -a | grep -E '^\s+' | awk '{print $1 "       -- " $2, $3, $4, $5}'
  )
}

_fzf_comprun() {
  local command=$1
  shift

  case "$command" in
    tree)         find . -type d | fzf --preview 'tree -C {} -I node_modules' "$@";;
    cd)         find . -type d | fzf --preview 'tree -C {} -I node_modules' "$@";;
    *)            fzf "$@" ;;
  esac
}

你还可以在这个本文里看到很多我使用 fzf 配合其它工具的例子。

git

应该没人不知道了,但你可能不知道,如果你使用的 zsh,那它提供了一系列的 git 的别名:Oh-My-Zsh Git,这里还有一些我与 fzf、difft 等工具配合使用的例子:

# 更好的 git diff
gd() {
  GIT_EXTERNAL_DIFF=difft git diff
}

# 更好的 git show
gsh() {
  GIT_PAGER_IN_USE=1 GIT_EXTERNAL_DIFF=difft git show "$1" --ext-diff | less
}

ggfzf() { fzf --no-sort --ansi --bind "enter:execute(echo {} | sed 's/\x1b\[[0-9;]*m//g' | cut -d' ' -f2 | xargs -I {} bash -c \"source ~/.bash_profile && gd {}\")"; }

# git 提交列表,按回车可快速查看提交信息
gglg() {
  git log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit | ggfzf
}

h

history | fzf 的别名。

hg

与 git 差不多的版本控制工具,主要我司内部使用这个工具。

虽然我自己也有一套相关的 alias,但由于这个工具用户不多,分享的意义不大,就先不列出来了。

htop

也是更好的 top。

ii

查看当前系统的一些信息。

ii() {
  echo -e "\n您已登录 ${RED}$HOST"
  echo -e "\n附加信息:$NC "
  uname -a
  echo -e "\n${RED}Users logged on:$NC "
  w -h
  echo -e "\n${RED}Current date :$NC "
  date
  echo -e "\n${RED}Machine stats :$NC "
  uptime
  echo -e "\n${RED}Memory stats :$NC "
  free
  echo -e "\n${RED}Diskspace :$NC "
  df -h
  echo -e "\n${RED}Local IP Address :$NC"
  myip
  echo
}

jq

对 json 文件进行操作、过滤等。

jnv

交互式的 jq。

lsd

更好的 ls。

ns

快速打开一个临时目录,做任何实践性的操作。

ns() {
  newdir=`date +%s`
  fulldir=~/Temp/$newdir
  mkdir -p $fulldir
  cd $fulldir
}

nvm

一开始我用的就是这个老牌的 Node.js 版本管理工具,但后来发现它让终端启动太慢,实在受不了,于是换成了 n。可惜在工作中又要求统一用 nvm,只好又换回去了。对于终端启动慢的问题,可以通过冷启动来解决。现在用下来还是能接受的。

# ~/.zshrc
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

# install zsh-async if it’s not present
if [[ ! -a ~/.zsh-async ]]; then
  git clone git@github.com:mafredri/zsh-async.git ~/.zsh-async
fi
source ~/.zsh-async/async.zsh

load_nvmrc() {
  local nvmrc_path
  nvmrc_path="$(nvm_find_nvmrc)"

  if [ -n "$nvmrc_path" ]; then
    local nvmrc_node_version
    nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")

    if [ "$nvmrc_node_version" = "N/A" ]; then
      nvm install
    elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
      nvm use > /dev/null
    fi
  elif [ -n "$(PWD=$OLDPWD nvm_find_nvmrc)" ] && [ "$(nvm version)" != "$(nvm version default)" ]; then
    nvm use default > /dev/null
  fi
}

# async_loader
function async_loader() {
  # nvm
  export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
  [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
  load_nvmrc
}

# init worker
async_start_worker async_load_worker -n
async_register_callback async_load_worker async_loader
async_job async_load_worker sleep 0.1

# 改变目录时自动切换 Node.js 版本
add-zsh-hook chpwd load_nvmrc

mdcat

更好地渲染 markdown 的 cat 命令,支持高亮 markdown 语法、代码块、图片等。

ppwd

快速复制当前路径:alias ppwd=pwd | pbcopy

projclean

项目依赖关系和构建工件清理工具,支持大部分项目。

对于前端来说,它可以帮你一键删除所有项目中的 node_modules 目录,从而释放硬盘空间。

cargo install projclean

# 谨慎操作!!!
projclean node_modules

r

快速切换到当前仓库的根目录。

r() {
  if is_git; then
    cd "$(git rev-parse --show-toplevel)" || return
  elif is_hg; then
    cd "$(hg root)" || return
  else
    cd ~ || return
  fi
}

rsync

rsync 是 linux 系统下的数据镜像备份工具。使用快速增量备份工具。

s

快速应用 .zshrc 等文件而无需重启终端,source ~/.zshrc

server

快速打开一个 http 服务器:python3 -m http.server 8181

shellcheck

顾名思义,检查 shell 脚本用的,类似于 eslint 之于 JavaScript。

tldr

查询某个命令的使用示例。

tree

输出当前树形目录结构。

trash

代替 rm,不会直接删除,而是把文件或目录移动到回收站,让你有后悔药吃,可配合 Raycast 的 Empty Trash 指令快速清空回收站。

upp

一键通过 vim 编写提交信息,并进行推送。

git add -A
temp_file=$(mktemp)
vim +startinsert "$temp_file"
commit_message=$(cat "$temp_file")

if [ -z "$commit_message" ]; then
  echo "Commit message was empty. Aborting."
  rm -f "$temp_file"
  return
fi

echo -n "Commit message:"
echo " $commit_message"

echo -n "Do you want to push? (y/N): "

read confirm

git commit -m "$commit_message"

[ $? -eq 1 ] && exit 1

if [[ "$confirm" =~ ^[Yy]$ ]]; then
  git push
fi

rm -f "$temp_file"

vo

使用 fzf 模糊搜索文件并通过 vim 打开:

vo() {
  selected_file=$(_fzf)
  if [ $? -eq 0 ]; then
    nvim "$selected_file"
  fi
}

以此类推可以实现快速通过其它工具打开。

wx

快速通过微信开发者工具当前项目:

wx() {
  /Applications/wechatwebdevtools.app/Contents/MacOS/cli open --project "$(pwd)/$1"
}

yt-dlp

youtube-dl 已死,这是它的代替方案。

z

我以前一直用 z.lua,直到我发现了 zoxide。我觉得这是一个更好的快速跳转目录工具。

而且它还支持使用 zi 命令列出候选目录,然后你可以自己选择要跳转的目录。

zsh

这几年我一直用 zsh,试过一段时间 fish,但发现不太习惯,而且不兼容我的 bash 脚本,所以还是回到了 zsh。目前我在用 p10k 主题,挺不错的!

插件主要是这些:

plugins=(git mercurial-prompt mercurial zsh-autosuggestions fzf-tab zsh-vi-mode pnpm-shell-completion zsh-history-substring-search zsh-syntax-highlighting)

npm 包

这里列出一些可能你不知道的 npm 包,它们能帮你提升开发效率。

c8

生成代码覆盖率报告的神器,基于 Node.js 原生功能,简单好用。

cli-highlight

让终端输出的代码瞬间“高亮”,看日志的快乐你懂的。

fast-glob

速度飞快的文件匹配工具,性能吊打 glob,但只能通过代码调用。

const fg = require("fast-glob");
const entries = await fg([".editorconfig", "**/index.js"], { dot: true });

glob

经典的文件匹配工具,写脚本必备,还支持直接在命令行里用:

glob 'src/**/*.sh' | xargs shellcheck

jscpd

专治代码“复读机”,用来检测代码重复率,保质保量:

jscpd -p "./**/*.js" -k 15 -l 5

ni / nr

自动匹配项目用的包管理器,ni 安装依赖,nr 快速跑脚本,效率拉满。

nolyfill

专治“历史包袱”的工具,替换掉那些为兼容老 Node.js 而存在的多余 polyfill,立减 node_modules 大小 50MiB 以上。

npm-run-all

并发或串行跑 npm scripts,简单又高效。

npm-run-all --parallel lint:*"

npm-check-updates

批量更新依赖版本,想升就升,谁还手动改 package.json。

ncu -i -t latest --packageFile "generators/**/_package.json"

minimist

Node.js 脚本写多了少不了它,轻松解析命令行参数。

typedoc

用 jsdoc 写 TypeScript 注释,生成文档专用。

pnpm typedoc --options typedoc.json

vue-docgen

需要给 Vue 组件生成组件文档?用它,省时省力不糊涂。

结语

以上就是我 2024 年常用的工具,希望能对你有所帮助。当然,每个人的需求和喜好不同,适合我的不一定适合你。如果你有其他好用的工具推荐,欢迎在评论区分享!你最常用的工具是什么?你有什么提高效率的技巧?一起来讨论吧!

对 RSS 的再思考:被忽视的力量

引言

作为一名多年的 RSS 重度用户,我曾通过 RSS 订阅几乎所有你能想到的内容:技术博客、社区帖子、Twitter(现称为 X)的动态、Telegram 消息、YouTube 更新等。得益于开源项目 RSSHub,我可以轻松地制作和订阅各种源。

我甚至尝试过将整本书的所有章节、某个网站的全部内容制作成 RSS 源,每天推送一篇给自己。这种方式既新颖又有趣。

然而,在 2023 年,我几乎停止了使用 RSS。主要原因是我意识到自己花在信息流上的时间过多,影响了工作和生活的其他重要方面。每天花费数小时浏览信息流,大多只是快速浏览标题,真正深入阅读的内容寥寥无几。停用 RSS 后,我突然发现自己有了大量空闲时间,可以专注于看电影、学习新知识,而不必担心信息的积压。

尽管暂停使用 RSS 可能会错过一些新鲜事,但实际上,那些重要的信息往往会通过其他渠道传递给我,例如微信群和朋友的分享。

到了 2024 年,经过一段时间的反思和自我调整,我重新拾起了阅读 RSS 的习惯,在这过程中对 RSS 有了一些新的看法。

RSS 已死?

“RSS 已死”的论调时有耳闻。持这种观点的人通常会举出以下例子:

首先,像 Google 这样的科技巨头早在十多年前就放弃了对 RSS 的支持,似乎各大公司都在有意无意地边缘化这项技术。国内的微信构建了一个封闭的生态系统,其他以 RSS 为卖点的产品在国内市场也难以立足,甚至面临下架的风险。

即使在国外,情况也不容乐观。Twitter 在被马斯克收购并改名为 X 后,进一步收紧了对 RSS 的限制,甚至对 RSSHub 的实例发动了 DDoS 攻击。

在这样的打压下,RSS 似乎已被时代遗忘。就我身边的情况来看,多年来与我共事的同事中,使用 RSS 的人寥寥无几,甚至有人根本不知道 RSS 为何物。

然而,2024 年初,RSSHub 的作者 DIYgod 联合推出了一款新的 RSS 阅读器——Follow。这款产品的出现,引起了广泛的关注。由于邀请码稀缺,各大论坛充斥着关于 Follow 的抽奖和邀请码交易的帖子。一时间,RSS 似乎又回到了大众的视野。

在独立开发者的圈子里,也有越来越多的人开始讨论开发自己的 RSS 阅读器产品。Follow 的出现,的确让一些原本不了解 RSS 的人开始关注和使用它。这似乎与“RSS 已死”的论调相矛盾,RSS 真的要复兴了吗?

被忽视的力量

那么,Follow 的出现能否让 RSS“死而复生”呢?我对此持谨慎的态度。我的看法与一篇文章中所表达的观点非常相似。

我认为,正是因为国内外的科技巨头对 RSS 缺乏兴趣,他们才没有过多干涉 RSS 的发展。这些公司借鉴了 RSS 的理念,打造了自己的封闭式信息流产品,如微信公众号等,但这并不是真正的 RSS。恰恰是这种“被忽视”,使得 RSS 得以保持其纯粹性和开放性。

相反,对于邮件订阅等渠道,科技巨头们却非常热衷,结果导致用户经常收到大量的营销邮件,甚至需要花费精力去取消订阅。

因此,被主流所忽视,反而成为了 RSS 的护身符。它不受商业利益的驱使,能够为用户提供一个自主选择信息的工具。在信息茧房泛滥的今天,RSS 为少数坚持自主获取信息的人提供了一片净土。

Follow 的出现,虽然在一定程度上提高了 RSS 的关注度,但我认为更重要的是它为 RSS 注入了新的活力,让更多人认识到 RSS 的价值。然而,RSS 的核心魅力在于其开放性和用户自主性,这些特质正是由于缺乏商业巨头的干涉而得以保留。

结语

或许,RSS 并不需要复兴,因为它从未真正消亡。它一直存在于那些追求信息自由和自主选择的人们的工具箱中。被忽视,反而使 RSS 免受商业化的侵蚀,保留了其原本的纯粹。这种在夹缝中生存的状态,或许正是一种另类的胜利。

再见 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 样式的工具。
❌