本博客首页的筛选器有三个维度:体裁、主题、标签。三个维度完全对称——都从 frontmatter 动态汇总,没有预设枚举;层内多选是「或」,层间是「与」;每一层的可选项会随其它两层的当前选择自适应地隐藏——把「当前条件下没有任何文章」的选项收起来。这篇笔记把这个问题和算法形式化,说明它其实是一个很小的代数结构。

1. 问题

设全部文章为集合 \(P\)\(|P| = N\)。三个维度记为

\[\mathcal{D} = \{g, t, k\} \qquad (g=\text{体裁},\ t=\text{主题},\ k=\text{标签})\]

每个维度有自己的取值域 \(V_g, V_t, V_k\),从所有文章的 frontmatter 汇总而来。

关键观察:体裁和主题是单值属性,标签是多值属性。为了统一处理,我们让每篇文章在每个维度上都带一个取值集合,而不是单个值:

\[\sigma_d : P \to 2^{V_d}, \qquad p \mapsto \sigma_d(p)\]

单值属性只是多值属性的退化情形。下面所有结论都不需要区分两者。

2. 匹配:一条式子概括全部语义

一次筛选状态是一个三元组

\[A = (A_g, A_t, A_k), \qquad A_d \subseteq V_d\]

其中 \(A_d = \varnothing\) 表示「该维度不设限制」,对应界面上的「全部」。

文章 \(p\) 满足筛选 \(A\),记 \(p \models A\),当且仅当

\[p \models A \;\iff\; \forall d \in \mathcal{D}:\ \big( A_d = \varnothing \ \lor\ \sigma_d(p) \cap A_d \ne \varnothing \big)\]

对单值维度,\(\sigma_d(p) \cap A_d \ne \varnothing\) 退化为 \(f_d(p) \in A_d\)(所选值命中该文章的那一个值);对标签维度,它就是「文章的标签集与所选标签集相交非空」——选中任一标签即命中。这正是代码里 matchGenre / matchTheme / matchTag 三条判断。

结果集定义为

\[R(A) \;=\; \{ p \in P \mid p \models A \}\]

3. 结果集 = 交并表达式

为每个取值 \(v \in V_d\) 定义它的外延(value class)——拥有该值的全部文章:

\[\sigma_d^{-1}(v) \;=\; \{ p \in P \mid v \in \sigma_d(p) \}\]

那么结果集可以写成一条极其紧凑的式子:

\[R(A) \;=\; \bigcap_{d \in \mathcal{D}}\ \bigcup_{v \in A_d} \sigma_d^{-1}(v)\]

并约定空并 \(\bigcup_{v \in \varnothing} \cdot = P\)(空选择即全集)。

这一条式子同时封装了三件事:

  1. 层内或:同一维度内是并集 \(\bigcup_{v \in A_d}\)
  2. 层间与:跨维度是交集 \(\bigcap_{d}\)
  3. 空即全部\(A_d = \varnothing\) 时对应因子退化为 \(P\),即恒等元。

注意「全部」映射到的是 \(A_d = \varnothing\),而不是 \(\bigcup_{v \in V_d}\)。两者在「有文章该维度取值为空」时不同:若某篇文章没有体裁,选遍所有体裁仍会漏掉它,但点「全部」不会。这个细微差别,正是代码里 v === '' 分支把选择重置为空数组的原因。

4. 单调性:筛得越多,结果越少

在选择空间 \(\prod_{d} 2^{V_d}\) 上定义细化序

\[A \le A' \;\iff\; \forall d:\ A_d \subseteq A'_d\]

即「\(A'\) 在每个维度上选的都更多(或相等)」。于是有

\[A \le A' \;\implies\; R(A') \subseteq R(A)\]

\(R\) 是一个反单调(antitone)映射:约束只增不减,结果只减不增。这是分面导航得以成立的根基——它保证用户的每一次点击都不会凭空多出文章,筛选是可预期、可撤销的。

更进一步,\(R\) 的反单调性正是形式概念分析(Formal Concept Analysis, FCA)中意图—外延 Galois 连接的同一类结构:把每个维度当作多值属性、把「文章 \(p\) 在维度 \(d\) 取到值 \(v\)」当作形式背景的关联,本文的筛选只是这个背景上的一种特定复合查询。这里不展开,只说明:这个看似平凡的前端功能,坐落在一个被研究得很透的代数结构里。

5. 自适应选项:可用性判定

「自适应」指:某维度的一个选项,若在当前其它维度的选择下没有任何文章匹配它,就隐藏起来,避免把读者引向空结果。

对维度 \(d\),定义「除 \(d\) 外」的约束结果集:

\[R_{\setminus d}(A) \;=\; \bigcap_{e \ne d}\ \bigcup_{v \in A_e} \sigma_e^{-1}(v)\]

即只用其它维度当前的选择去筛选文章。那么维度 \(d\) 的取值 \(v\)\(v \ne \varnothing\)\(v \notin A_d\)可用当且仅当

\[\sigma_d^{-1}(v) \cap R_{\setminus d}(A) \;\ne\; \varnothing\]

读作:存在至少一篇文章,它在维度 \(d\) 上取到 \(v\),同时满足其它所有维度的当前选择。

这正好对应代码里的两个函数:

代码还叠加了两条UX 不变式,它们不属于纯可用性判定:

  1. 「全部」(空值)永远显示——它是重置入口;
  2. 当前已选中的选项永远显示——哪怕它在其它维度变化后已无文章可配,也必须可见,否则读者无法取消它。这正是 if (!v || sel.includes(v)) return; 这行守卫的含义。

6. 算法与复杂度

整个刷新流程 refresh() 依次做三件事:

  1. syncAvailability() —— 重算各维度选项可用性;
  2. syncChips() —— 把 \(A\) 同步到 DOM 的 data-active
  3. filterCards() —— 对每张卡片判定 \(p \in R(A)\),显示/隐藏。

其中 filterCards\(N\) 张卡片各做 \(|\mathcal{D}| = 3\) 次判断,复杂度 \(O(ND)\)

syncAvailability 的朴素实现是三重循环:对每个维度(\(D\) 个)的每个选项(共 \(M = \sum_d |V_d|\) 个)扫一遍全部 \(N\) 张卡片,每张卡片再检查 \(D-1\) 个其它维度。复杂度

\[O\big( N \cdot D \cdot M \big)\]

\(D = 3\) 下就是 \(O(NM)\)。博客量级 \(N \sim 10^2\)\(M \sim 10\),每次点击大约 \(10^3\) 次成员判断,肉眼无感,所以选择了「前端全量渲染 + 即时暴力筛选」这个最简单的正确实现。

可用的优化路线,本质是把「扫卡片算外延」换成预计算外延

这也就是已知不足所指的方向:文章上千后,全量渲染 + 前端扫描会让首屏变重,届时再考虑分页或服务端筛选——但那只是把 \(P\)\(\sigma_d^{-1}\) 的载体从前端数组换成数据库,本文的语义模型一条都不用改。

7. 小结

把问题装进集合语言后,整个筛选器只剩两个式子:结果集

\[R(A) \;=\; \bigcap_{d}\ \bigcup_{v \in A_d} \sigma_d^{-1}(v)\]

和可用性判定

\[\sigma_d^{-1}(v) \cap R_{\setminus d}(A) \;\ne\; \varnothing\]

前者一句话说完「层内或、层间与、空即全部」,后者一句话说完「自适应选项」。剩下的单调性、Galois 对应、复杂度,都是这两条式子的直接推论。filter.js 里约一百行代码,本质上就是这两个式子的朴素求值器——先正确地实现语义,再在需要时优化求值方式,这是我更喜欢的顺序。