本博客首页的筛选器有三个维度:体裁、主题、标签。三个维度完全对称——都从 frontmatter 动态汇总,没有预设枚举;层内多选是「或」,层间是「与」;每一层的可选项会随其它两层的当前选择自适应地隐藏——把「当前条件下没有任何文章」的选项收起来。这篇笔记把这个问题和算法形式化,说明它其实是一个很小的代数结构。
1. 问题
设全部文章为集合 \(P\),\(|P| = N\)。三个维度记为
每个维度有自己的取值域 \(V_g, V_t, V_k\),从所有文章的 frontmatter 汇总而来。
关键观察:体裁和主题是单值属性,标签是多值属性。为了统一处理,我们让每篇文章在每个维度上都带一个取值集合,而不是单个值:
- 体裁、主题:\(|\sigma_d(p)| \le 1\)(单值,可能为空——文章可以没有主题);
- 标签:\(\sigma_k(p) = S(p)\)(一篇文章的标签集合,可为空)。
单值属性只是多值属性的退化情形。下面所有结论都不需要区分两者。
2. 匹配:一条式子概括全部语义
一次筛选状态是一个三元组
其中 \(A_d = \varnothing\) 表示「该维度不设限制」,对应界面上的「全部」。
文章 \(p\) 满足筛选 \(A\),记 \(p \models A\),当且仅当
对单值维度,\(\sigma_d(p) \cap A_d \ne \varnothing\) 退化为 \(f_d(p) \in A_d\)(所选值命中该文章的那一个值);对标签维度,它就是「文章的标签集与所选标签集相交非空」——选中任一标签即命中。这正是代码里 matchGenre / matchTheme / matchTag 三条判断。
结果集定义为
3. 结果集 = 交并表达式
为每个取值 \(v \in V_d\) 定义它的外延(value class)——拥有该值的全部文章:
那么结果集可以写成一条极其紧凑的式子:
并约定空并 \(\bigcup_{v \in \varnothing} \cdot = P\)(空选择即全集)。
这一条式子同时封装了三件事:
- 层内或:同一维度内是并集 \(\bigcup_{v \in A_d}\);
- 层间与:跨维度是交集 \(\bigcap_{d}\);
- 空即全部:\(A_d = \varnothing\) 时对应因子退化为 \(P\),即恒等元。
注意「全部」映射到的是 \(A_d = \varnothing\),而不是 \(\bigcup_{v \in V_d}\)。两者在「有文章该维度取值为空」时不同:若某篇文章没有体裁,选遍所有体裁仍会漏掉它,但点「全部」不会。这个细微差别,正是代码里 v === '' 分支把选择重置为空数组的原因。
4. 单调性:筛得越多,结果越少
在选择空间 \(\prod_{d} 2^{V_d}\) 上定义细化序:
即「\(A'\) 在每个维度上选的都更多(或相等)」。于是有
\(R\) 是一个反单调(antitone)映射:约束只增不减,结果只减不增。这是分面导航得以成立的根基——它保证用户的每一次点击都不会凭空多出文章,筛选是可预期、可撤销的。
更进一步,\(R\) 的反单调性正是形式概念分析(Formal Concept Analysis, FCA)中意图—外延 Galois 连接的同一类结构:把每个维度当作多值属性、把「文章 \(p\) 在维度 \(d\) 取到值 \(v\)」当作形式背景的关联,本文的筛选只是这个背景上的一种特定复合查询。这里不展开,只说明:这个看似平凡的前端功能,坐落在一个被研究得很透的代数结构里。
5. 自适应选项:可用性判定
「自适应」指:某维度的一个选项,若在当前其它维度的选择下没有任何文章匹配它,就隐藏起来,避免把读者引向空结果。
对维度 \(d\),定义「除 \(d\) 外」的约束结果集:
即只用其它维度当前的选择去筛选文章。那么维度 \(d\) 的取值 \(v\)(\(v \ne \varnothing\) 且 \(v \notin A_d\))可用当且仅当
读作:存在至少一篇文章,它在维度 \(d\) 上取到 \(v\),同时满足其它所有维度的当前选择。
这正好对应代码里的两个函数:
matchesOther(card, dim)判定一篇文章是否落在 \(R_{\setminus d}(A)\) 里(逐维度检查「除 \(d\) 外是否命中」);syncAvailability()对每个维度、每个选项 \(v\) 扫一遍所有卡片,看是否存在满足 \(\sigma_d^{-1}(v) \cap R_{\setminus d}(A) \ne \varnothing\) 的文章;不存在则c.hidden = true。
代码还叠加了两条UX 不变式,它们不属于纯可用性判定:
- 「全部」(空值)永远显示——它是重置入口;
- 当前已选中的选项永远显示——哪怕它在其它维度变化后已无文章可配,也必须可见,否则读者无法取消它。这正是
if (!v || sel.includes(v)) return;这行守卫的含义。
6. 算法与复杂度
整个刷新流程 refresh() 依次做三件事:
syncAvailability()—— 重算各维度选项可用性;syncChips()—— 把 \(A\) 同步到 DOM 的data-active;filterCards()—— 对每张卡片判定 \(p \in R(A)\),显示/隐藏。
其中 filterCards 对 \(N\) 张卡片各做 \(|\mathcal{D}| = 3\) 次判断,复杂度 \(O(ND)\)。
syncAvailability 的朴素实现是三重循环:对每个维度(\(D\) 个)的每个选项(共 \(M = \sum_d |V_d|\) 个)扫一遍全部 \(N\) 张卡片,每张卡片再检查 \(D-1\) 个其它维度。复杂度
在 \(D = 3\) 下就是 \(O(NM)\)。博客量级 \(N \sim 10^2\)、\(M \sim 10\),每次点击大约 \(10^3\) 次成员判断,肉眼无感,所以选择了「前端全量渲染 + 即时暴力筛选」这个最简单的正确实现。
可用的优化路线,本质是把「扫卡片算外延」换成预计算外延:
- 位图索引:把每个 \(\sigma_d^{-1}(v)\) 存成长度为 \(N\) 的比特向量,则 \(R(A)\) 与 \(R_{\setminus d}(A)\) 都是若干向量的按位「先或后与」,可用性判定退化为「按位与后非零」。复杂度降到 \(O\big( (N/64) \cdot D \cdot M \big)\),常数改善数十倍。
- 计数视角:可用性等价于计数 \(c_d(v) = \left|\sigma_d^{-1}(v) \cap R_{\setminus d}(A)\right|\) 是否为正。数据库里的 faceted search 就是预先按维度
GROUP BY维护这些计数,查询时取数而非现算。
这也就是已知不足所指的方向:文章上千后,全量渲染 + 前端扫描会让首屏变重,届时再考虑分页或服务端筛选——但那只是把 \(P\) 和 \(\sigma_d^{-1}\) 的载体从前端数组换成数据库,本文的语义模型一条都不用改。
7. 小结
把问题装进集合语言后,整个筛选器只剩两个式子:结果集
和可用性判定
前者一句话说完「层内或、层间与、空即全部」,后者一句话说完「自适应选项」。剩下的单调性、Galois 对应、复杂度,都是这两条式子的直接推论。filter.js 里约一百行代码,本质上就是这两个式子的朴素求值器——先正确地实现语义,再在需要时优化求值方式,这是我更喜欢的顺序。