为Fava的日记账页面提速
Fava是Beancount的一个功能强大的Web UI,便于查看账本中的内容。然而它有一个长期存在的问题:当你的记录非常多时,日记账页面需要很长时间才能加载。我使用Beancount了超过五年,账本里有超过15k条记录,每次打开日记账页面都需要好几秒钟。
显然,我不是唯一一个受此问题影响的人,有些人的账本甚至比我的大得多。我记得过去看到过的建议有两种:
- 只打开选定时间区间的日记账,如一年内的。
- 按年份拆分账本,让Fava一次只打开一年的账本。
我感觉很难采纳这些建议,于是宁可完全不打开日记账页面。但我偶尔还是会有意无意地打开它,每次都让我很烦。于是我决定深入研究一下,看看能做些什么来改善它。
初次尝试
我做的第一件事是找出导致它变慢的原因。最初的调查是在很久以前做的,大概在2022年就开始研究这个问题,当时我的账本里只有大约4k条记录。我记得我首先查看了日记账页面请求的网络计时。请求在“等待”阶段花费了很长时间,而且页面本身也相当大。当我用性能分析工具分析Fava的Python代码时,发现大部分时间都花在了Jinja模板引擎渲染日记账表格的模板上。

在模板引擎中并没有什么显而易见的、容易解决的问题,所以我猜想可能只是生成的内容太多了。因此,我首先尝试减少生成的内容量。
我注意到price记录从未在日记账中显示。日记账中的所有记录默认都被这条CSS规则隐藏了:
.journal > li {
display: none;
}
只有当对应类型的开关打开时,该类型的记录才会显示。price记录没有对应的开关,但它们仍然被包含在生成的内容中。基于这个发现,我的第一个改动就是直接在模板中跳过渲染任何price记录。
这个改动能带来多大帮助取决于一个人有多少price记录。就我而言,我为大约20种commodity每月自动获取价格,即每年累积约250条记录。这些记录占我所有记录的约7%左右。虽然不是什么很大的数字,但对于一个简单的单行修改,这个改进仍然是值得的。
顺着同样的思路,我提了一个问题,询问是否可以按需加载过账条目(postings),而不是在初始渲染时就全部包含进来。在我看来,只有当我点击一笔交易的指示符时,过账条目才会显示,而它们却占了模板渲染内容很大一部分(对我来说超过60%)。花费60%的时间来渲染我很少需要的东西,感觉非常浪费。但这个提议遇到了一些阻力,因为可能有些用户希望通过对应开关来让过账条目一直显示。
分页
随着我账本中记录数量的增长,这个问题也越来越困扰我。有了AI开发工具1,我决定再试一次,采用一种更持久的方法。
有一个想法是对日记账进行分页,这样就不需要一次性渲染所有内容。可以先渲染首先显示的部分,剩下的稍后完成。而这正是我所实现的方案。
Fava通过其journal.html模板来渲染日记账页面,该模板包含了来自_layout.html的骨架和来自_journal_table.html的主日记账表格。当请求的URL里没有?partial=true参数时,_layout.html基础模板会渲染Fava页面的骨架。当从其他页面切换到日记账页面时,浏览器实际上会请求/journal/?partial=true来仅获取表格部分。
日记账表格的结构如下:
<fava-journal>
<ol class="flex-table journal">
<li class="head">
<!-- 表头 -->
</li>
{% for entry in entries %}
<li><!-- 记录的渲染 --></li>
{% endfor %}
</ol>
</fava-journal>
<fava-journal>是一个自定义元素,由前端的FavaJournal类处理。
基于这个结构,我的计划是:
- 在
journal.html模板中进行分页。当记录数量超过每页限制时,它会对记录进行切片,返回第一部分,并通过<fava-journal>自定义元素上的一个属性传递总页数。 - 然后在
FavaJournal类中,如果发现该属性存在,就并发地获取所有剩余页面,再按顺序将它们追加到列表中。
这个计划效果很好,它显著缩短了初始页面的渲染时间,让我能更快地看到我的日记账页面。但有一个问题:顺序。
我想大多数正常人都会期望日记账页面首先显示最新的记录,至少我是这么想的,而这也是Fava的默认设置。然而输入到模板的记录是按时间顺序排列的,也就是说最早的记录排在最前面。但要为分页改变顺序并不像反转输入数据那么简单,这与日记账页面上排序的实现方式有关。
回到上面的模板,在表头部分,有类似这样的元素:
<span class="datecell" data-sort="num" data-sort-name="date">日期</span>
<span class="flag" data-sort="string" data-sort-name="flag">F</span>
在每个列表项中则有:
<span class="datecell" data-sort-value="{{ loop.index }}">{{ entry.date }}</span>
<span class="flag">{{ entry.flag }}</span>
排序代码的工作方式是,它解析表头上的data-sort和data-sort-name,然后根据每个列表项中对应元素的data-sort-value值(如果有的话)或文本内容对列表项进行排序。到目前为止,Fava一直使用循环索引作为日期字段的排序值,这样按日期排序时,它实际上遵循的是Beancount内部的记录顺序。但现在排序值需要同时考虑分页和反转了。
最初我用了一些小聪明的方法,如使用负数排序值。在与Fava的维护者讨论后,我们最终决定为每个条目生成一个索引,并用它来填充这个排序值,以确保顺序正确。2
Flexbox
我做的另一个小优化是让日记账表格使用display: flex。这是我在对排序代码进行性能分析时发现的。使用默认的display: block时,尽管所有子元素都应该是块级元素,Firefox似乎还是花费了大量时间来解析文本的行。在这种情况下,切换到display: flex显著减少了更新DOM所花费的时间,因为flexbox布局不涉及行的概念。
然而这个优化似乎对Chrome没有影响,这可能意味着Firefox的块布局算法还有优化的空间。
结论
现在日记账页面的加载速度快多了,即使账本中有大量的记录,页面也能立即显示出来。实际上,页面在后台完全加载的总时间可能更长了,但它的感知性能要好得多,并且可以更早地进行交互。它也保留了所有内容(在加载完成后)仍在页面中的特性,所以如果用户想进行页内搜索或滚动到某个位置以快速跳转到之前的时间点,他们仍然可以做到。
从长远来看,维护者表示他考虑的方向是将渲染移至前端,并可能使用虚拟列表。这听起来是一个合理的方案,至少它将大大减少后端和浏览器之间传输的数据量,并可能使排序等功能更容易实现且更不容易出错。性能可能也更好,原因有二:
- 渲染可以更好地适应本地设置(例如,不渲染隐藏的记录,默认不渲染过账条目,直接按正确的顺序渲染),以及
- 一般地说,人们投入了更多资源给JavaScript及其框架以使其运行更快,远远多于投入到Python的。
已经有其他人提交了一个草稿PR,研究将日记账的渲染移到客户端。这是一个大改动,但希望它也能带来巨大的好处。日记账页面似乎是唯一一个仍在服务器端渲染的主要页面。
但就目前而言,分页可能是一个不错的折衷方案,它以最小的改动让页面变得更快。