春节假期虽然是假期,但是在家的时间里我基本没闲着,毕竟按照传统(指 Ken Thompson 在假期里写出了 UNIX 的第一个雏形的传统,不过维基百科上并没有详述是不是这样,我也懒得考证),假期是造轮子的好机会。所以我就胡乱捣鼓一通看看能不能让我的博客生成器更圆一点。

重新理解 await

第一个简单的修改就是把启动时加载文件的部分并行化,其实我也没有非要尽可能并行,性能也不是我第一位的追求。

我一开始写代码的时候知道有 await 之后就开始在各种地方给 Promise 用 await,随便你怎么说都行,后来我也才意识到,给所有的 Promise 都加上 await 不就是相当于每一个 Promise 都写在上一个的 then 里面,那不还是顺序执行吗?写多了以后对异步有理解了我才想起来这里完全可以用 Promise.all(),反正加载插件/脚本/模板/语言是不太有先后依赖顺序的(非要说的话,插件和脚本里可能包含模板引擎,所以可能下一步是给前两个和后两个分开顺序加载)。

程序解决不了的事情就让人来做

随便你怎么说,我一直觉得有些程序很难理解的事情而人类很好理解的就该让人类来做,而不是费很大力气得到不好的效果。一个多少接近这一条的问题就是主题文件的依赖问题——很多生成器比如 Hexo 在以 server 模式运行的时候都有 watch 功能,可以监测文件变化然后实时重新生成,用户只要一刷新就可以看到最新的更改。直接看上去不难,写 Node 的谁还不知道 chokidar 了。但是问题就在于,普通用户写文章不太需要一边看渲染一边写(你不会连 Markdown 这么简单的格式都不能脑补吧?我鄙视 Typora),反而是主题作者经常需要看到自己刚编写的样式是什么样子,而这是一个很复杂的地方,CSS 预处理器和 HTML 模板引擎通常都有 import/include 一类的语法,让你不用写很多重复的片段,而博客生成器并不能理解每一种预处理器和模板引擎的语法。假如你在 A 模板里 include 了 B,然后修改了 B,此时你刷新一个用了 A 模板的页面,你是看不到页面更新的,那在最需要这个功能的时候这个功能变成了废物。

有一个简单的解决方案是不要预编译你的模板,而是每次需要渲染页面的时候重新读取模板文件编译出一个函数来。不过这对于生成器而言不太优雅,有些模板引擎比如 nunjucks 可能有内建的 cache 支持,但另一些可能是没有的。再说你来来回回读这么多次磁盘,那干嘛不直接把渲染页面的工作也挪到用户浏览器(指前端)里呢?还要博客生成器做什么。

我一开始的打算是既然生成器自己不好理解依赖语法,那就像处理博客文章一样,让主题作者给文件加上 front matter 记录这个文件的依赖。不过问题又来了,对于直接使用的模板(指博客生成器读取并编译它们)可以在读取时去掉 front matter,但是假如这个模板 include 的模板也带有 front matter,此时实际上是模板引擎(比如 nunjucks)自己去磁盘上读那个文件,它不会去掉 front matter。而给每一种模板引擎都重写文件加载器显然是不现实的,所以最后的方案就是要求主题作者提供一个 file-dependencies.yaml 的文件,里面记录每个文件的依赖关系,大概像是这样:

layouts:
  includes/layout.njk:
    - includes/footer.njk
    - includes/header.njk
    - includes/sidebar.njk

第一层是需要 watch 的目录名,因为不是所有的目录都需要实时刷新,显然这种耗时的操作越少越好,第二层是依赖其他文件的文件,第三层则是一个被它依赖的文件的列表。程序里面我写了一个 Watcher 类,启动时会读取这个文件,然后将它翻转过来:以被其它文件依赖的文件作为 key,而依赖这个文件的文件成为 value 列表。因为我们总是检测到被依赖的文件变化之后才开始统计受到它影响的文件。然后就是一个递归收集受到影响的文件的函数,因为文件依赖并不是只有一层的。收集之后分别给这些文件也加入到需要更新的列表里面。

写完这个功能之后我最大的感叹就是:要是我写主题的时候能有这种功能,我应该能节省不少检查的时间吧……

后面闲着又给这个做了点修改,一个是支持用 glob pattern 作为被依赖的文件,虽然我个人觉得还是把需要的文件都列出来算了,但是想必对于处在开发调试期的主题来说很有用,作者可能不一定想得起来及时更新这个文件。另一个是考虑到使用 glob 之后很容易在无意间搞出循环依赖,又写了在递归里打破循环依赖的功能,我通常不擅长处理递归的逻辑,但我脑子灵光一闪想起来可以给函数最后一个参数设置成一个记录已经处理过哪些文件的 Set,然后递归的时候作为参数传进去,然后就这么成了。

现在如果开启 server 模式,各种文件以及语言文件模板文件,甚至包括这个依赖描述文件自己都是实时更新的了,只有主题和站点的两个关键配置文件需要重启之后才能重新加载。

削减依赖的路上没有最少只有更少

事实上我在添加依赖的时候已经非常克制了,我尽量只使用那些我不得不用的 npm 包,有些功能能自己实现的我都自己实现了。但是总有还能去掉的依赖。我首先干掉了 Stylus,理由很哭笑不得,它好像不是那么流行了,我都找不到合适的支持它的 Emacs 插件。我本来想换成 Less 或者 SCSS,但是前者语法和 Stylus 差得有点多,后者有好几种语言的实现,本来我看 GNOME 都在用 C 写的 libsass,我觉得挺好,也打算用这个,结果发现它教程里挂着说“不推荐使用 @import 而推荐 @use 但目前只有 dart 版本的实现支持了后者”,让我感到说不出来的难受,我不太喜欢这种奇怪的新语言,而且搞这么多实现,还整出参差不齐的语法,实在是麻烦,虽然 dart 可以编译到 JavaScript,我还是放弃了。其实我自己写 Stylus 的时候不是放飞自我型的,而是尽可能贴近 CSS 型的,所以干脆把我的样式都改成了原生 CSS 了,顺手就干掉了一个依赖,如果有需要的人还是装插件吧。

再之后就是我一直使用 glob 来匹配文件,但是它还依赖一些别的包,我在想能不能用依赖更少的包替换它。glob 使用 minimatch 分析 glob pattern,但是比如 chokidar 使用的是另一个替代品叫 picomatch。我找到一个叫 fdir 的库,虽然接口用起来怪怪的,但是如果你用它做 glob,只需要它和 picomatch,没有引入别的依赖,于是我就试了一下。但是不得不感叹作者能不能写点阳间的代码,这个库默认忽略软链接文件,这显然是不能用的,但是假如你设置解析链接文件,它倒好,就算我要的是相对路径,它还是直接解析并拼接了绝对路径——我只是想让你把链接文件当普通文件返回一下!就在我打算放弃并用回阳间的 glob 包的时候,我发现 chokidar 自己依赖的 readdirp 库也可以配合 picomatch 使用。但是如果你直接把 glob pattern 丢给它让它自己调用 picomatch 编译的话,又有一些阴间的限制,索性可以给它传自定义的过滤器函数,正好我还需要支持一些别的功能,就自己编译 glob 传给它了。

实际上我现在只有 10 个左右的直接依赖,全部的运行依赖大概是 36 个,在 Node.js 编写的程序里面应该算是相当克制的了,考虑到这里面大部分都是 nunjucks 的间接依赖,我觉得我做得相当不错。

对于其它我编写的简单的 npm 包我还是比较追求 0 依赖,只有 0 依赖的包才是比较可控的,如果一个 npm 包在 npm 上显示有 1 个依赖,那你就不能确定这一个依赖有多少间接依赖,而不更新的间接依赖会引入多少安全问题你也无法得知。我在给 Hikaru 选依赖的时候一般也会倾向于 0 依赖的包(这就是为什么选择 commander.js 而不是 yargs 的原因)或者是一些虽然有依赖,但是递归翻几次就是 0 依赖的包(chokidar 属于这类,而且我还给 nunjucks 提过一个 PR 使用 commander.js 代替了 yargs),而不会选那些依赖摞依赖无穷无尽的包。

这里不得不吐槽一下 jsdoc、mocha 这些大型的开发用的工具库,依赖实在是太混乱了,甚至 jsdoc 似乎同时依赖 marked 和 markdown-it……开发者写新功能的时候能不能长点心啊。还有我之前用 react-scripts 的时候,用旧版提示依赖里面有的版本有安全问题,升级到新版依赖算了一大堆,甚至出现了不同版本的间接依赖,而且仍然是有问题的版本,还是希望开发者对自己的依赖都关心关心,及时更新依赖版本,不要锁死一个版本懒得改就觉得万事大吉了。

如果你对削减 npm 包的依赖感兴趣,强烈建议你读一下 chokidar 开发者写的这篇文章

worker_threads 是多线程,但和我想的不太一样……

我:你懂 Node 吗?为什么我多线程比单线程慢? 铁道迷:Node 就是单线程拖拉机啊。 我:你不懂,88。

凡是跟你说“Node.js 只有单线程”的人,都可以直接和他说“你不懂 Node.js”了。Node 要解决的就是非阻塞 IO 的问题,而非阻塞 IO 肯定不是多线程能解决的。至少对于内部的 fs 来说,调用异步函数的时候是 libuv 从线程池里面拉出一个线程去解决任务,而 JavaScript 本身的线程不会被阻塞。如果你还想了解“有哪些代码会阻塞主线程而哪些代码不会”,可以看 Node.js 官网的这篇文章

说实话,对于用户自己写的 JavaScript 代码段,似乎是没什么好办法把它丢到主线程之外的线程上去执行的……请务必注意“回调”,“异步”和“非阻塞”的区别——不是所有回调都是异步的,比如我传了一个回调函数它也可能是同步的,而就算你把一段耗时的同步代码套上 setImmediate 和 Promise,也不意味着它就不会阻塞主线程了……它执行的时候还是在主线程执行,只是你把它延迟了,推到了 event loop 空闲的时候去运行——但轮到它运行起来还是阻塞住了主线程。(说出来不怕笑话,我也是写得多了最近才认识到这些区别(乐),如果我写的不对还希望大家指正。)

起因是我读了 Sukka 的这篇文章,介绍了 Node 新加的 worker_threads 模块,是允许你开启子线程运行代码的。正好我想到我的博客生成器里有一段分析每篇文章内容并修饰里面的图片和链接的代码,要是能起一个线程池,把每个文章分配给不同的线程去处理,那多是一件美事啊!

我发现这个 worker_threads 并不是很好用,它并不像 pthread,直接运行一个函数,而是要求你必须传一个 JavaScript 脚本的路径进去,这就给代码编写搞出了很大的麻烦。我做的修改在另一个 worker 分支里面,可以访问 https://github.com/AlynxZhou/hikaru/commits/worker 看到。不过一个尴尬的事情是写好以后我跑了几次,发现多线程还没有我之前单线程运行快……而且很反直觉的是线程越多越慢,在我 12 核心的处理器上四个线程是跑在主线程之外最快的,比什么 2、6、8、12 都快。

说实话,我也没有搞清楚到底是怎么回事,可以确定任务确实是分发给了不同的线程,但是为什么会这么慢呢?一个可能的原因是在不同的线程之间并不是像 C 一样直接共享内存空间,传递参数的时候 Node 是复制之后传递的,虽然我觉得我传递的数据量不至于让复制造成瓶颈吧,但这是我能想到唯一的原因了。而且阅读例子的时候也发现,好像推荐的用法是比如做 http server,每一个线程都是启动之后一直监听,而不像我这样是不断的分配大量的小任务。总之我很需要一个像 pthread 那样简单的给我执行一段代码的线程,而 worker_threads 好像倾向长时间运行一个单独文件……强扭的瓜不甜,我还是放弃吧。

然后说回到性能这件事上,我倒不是觉得生成器现在的性能难以忍受,否则我就该用 Hugo,相反我实在是觉得现在的生成速度出乎意料的棒了,而且又不像 Hugo 只支持 go template,Node 有很多种模板引擎可以选(我就是不喜欢 go 你来打我呀)。完整生成一遍我的博客大概耗时 800 毫秒,而且我也没有把渲染啦解析啦之类的功能用多线程解决,并且我的生成器没有缓存,每次都是从头生成的。没有缓存这点其实和之前 watch 文件的功能很类似,不够可靠还不如不要算了。在我以前用 Hexo 的时候它是有缓存的,但是这个缓存经常导致奇怪的结果,比如该更新的页面没有伴随文章一起更新之类的,我也不是很理解到底怎么回事(特别是考虑到 Hexo 里面还有一个奇怪的 JSON 数据库 warehouse),所以每次我都是 clean 之后再从头生成,那我自己实现的时候自然就砍掉这种不靠谱的功能了。

当然我问了琪神 Hugo 生成他的博客耗时多少,似乎只需要 200 毫秒的样子,不过除了 go 是编译型语言而且 Hugo 肯定用了协程渲染以外,他只有 6 篇文章而我有 83 篇文章也是一个很重要的因素!他甚至连分页都不需要生成!

所以如果你真的只追求速度不追求别的,什么语言都是浮云,总结出来就一句话:不写文章的博客生成速度最快!

既然看了喵写的文章,不打算投喂一下再走吗?哼!