<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>To Ansonated</title><description>A commonplace book for things I build, read, and wonder about</description><link>https://blogs.ansonate.ink/</link><language>en</language><item><title>从 Desmos 到 CaLU：我的激活函数折腾手记 (I)</title><link>https://blogs.ansonate.ink/posts/desmos-and-calu/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/desmos-and-calu/</guid><description>从 Desmos 上的一条曲线到 CaLU 激活函数：在 MNIST 上的 45 次对照实验手记。</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;说明一下分工：灵感、猜想都是我的；代码和实验是 AI 写的，我只负责动嘴和动念头和训练。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;前几天三角函数复习累了，在 Desmos 上拖曲线玩，把 arctan 和 tanh 画到一张图里，越看越觉得它俩长得像——都是 S 形，都是两边饱和。区别无非是 $\tanh$ 值域是 $(-1, 1)$，$\arctan$ 是 $(-\frac{\pi}{2}, \frac{\pi}{2})$，横竖缩放一下就能重合。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/01_tanh_arctan.svg&quot; alt=&quot;tanh vs 缩放后的 arctan：原点处斜率同为 1，尾部一个指数收敛、一个多项式拖尾&quot; /&gt;&lt;/p&gt;
&lt;p&gt;那一刻冒出个念头：&lt;strong&gt;arctan 缩一缩能不能直接当激活函数？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但这个念头只活了三秒。因为答案是显而易见的：肯定不好。tanh 和 sigmoid 当年在深网里怎么死的，arctan 就得怎么死——两边饱和，导数 $\frac{1}{1+x^2}$ 往两边一滑就没了，层数一深梯度直接消失。这都不用跑实验，算一下导数上确界就知道。所以 arctan 原教旨主义当场放弃。&lt;/p&gt;
&lt;p&gt;但 Desmos 还开着，我就多看了两眼。顺手把 sigmoid 画了一下&lt;/p&gt;
&lt;h2&gt;关于 Swish 的那个&quot;坑&quot;&lt;/h2&gt;
&lt;p&gt;视线移到 Swish 身上：$f(x) = x\cdot\sigma(x)$。这条曲线的左半边有个很有名的小坑——$x\approx -1.28$ 处 dip 到 $-0.28$ 再爬回来。大家都说这个非单调的坑是 Swish 好用的原因之一，但我当时犯轴：&lt;strong&gt;如果我偏要把它掰直呢？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/02_swish_dip.svg&quot; alt=&quot;填坑：SiLU 的 dip，以及 soft leaky relu 用不同 eta 把它逐渐填平（eta=0.2 已严格单调）&quot; /&gt;&lt;/p&gt;
&lt;p&gt;最顺手的办法就是加一个 x term 进去，搞成漏的（leaky）：&lt;/p&gt;
&lt;p&gt;$$f(x) = (1-\beta)\cdot x\cdot\sigma(x) + \beta\cdot x$$&lt;/p&gt;
&lt;p&gt;$x\to +\infty$ 时趋近于 $x$，$x\to -\infty$ 时趋近于 $\beta\cdot x$——这不就是 Leaky ReLU 被磨光滑了吗？我给它起名叫 &lt;strong&gt;soft leaky relu&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;然后直接在 Desmos上看一下单调性。当 $\beta$ 取 $0.2$ 是稳单调的，$\beta$ 取 $0.01$ 则还留着那个坑。算了一下单调性。$f&apos;(x) = (1-\beta)\cdot[\text{SiLU 的导数}] + \beta$，而 SiLU 导数在 $x\approx -2.40$ 处有个最小值 $-0.0998$。令最小导数大于零，解出来 &lt;strong&gt;$\beta &amp;gt; 0.0908$ 时函数严格单调&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个 $0.09$ 的阈值后来变得很有意思——先按下不表。&lt;/p&gt;
&lt;h2&gt;sigmoid—tanh—arctan：为什么不魔改门控？&lt;/h2&gt;
&lt;p&gt;soft leaky relu 写出来之后，我盯着它看了很久，结构是 $x\cdot\mathrm{gate}(x) + \mathrm{leak}$。门控现在是 sigmoid，但我的联想链是这样的：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sigmoid 和 tanh 是亲戚&lt;/strong&gt;（$\tanh(x) = 2\sigma(2x) - 1$，小学二年级知识），&lt;strong&gt;tanh 和 arctan 长得像&lt;/strong&gt;（Desmos 告诉我的）——那为什么不把 arctan 魔改成门控，塞进同一个模板里试试？&lt;/p&gt;
&lt;p&gt;当时写下的原式是这样的：&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
T(x) &amp;amp;= \frac{\arctan(x)}{\pi/2} \
t(x) &amp;amp;= \frac{T(x)+1}{2} \
a(x) &amp;amp;= (1-\beta)\cdot x\cdot t(x) + \beta\cdot x
\end{align}
$$&lt;/p&gt;
&lt;p&gt;$T$ 把 $\arctan$ 压到 $(-1, 1)$，$t$ 再搬到 $(0, 1)$，正好是个门控。写完之后我随手把式子丢给 AI 看，它一眼就认了出来：$\frac{1}{2}\left(\frac{2\arctan(x)}{\pi}+1\right)$，这不就是……&lt;strong&gt;标准 Cauchy 分布的 CDF&lt;/strong&gt; 吗？$C(x) = \frac{1}{2} + \frac{1}{\pi}\arctan(x)$，一模一样。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/03_gates.svg&quot; alt=&quot;三家门控同框：sigmoid、Cauchy CDF、RoCLU 的 log 门，值域都是 (0,1)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;顺着这个提示，swish、gelu 全连起来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Logistic CDF → SiLU/Swish&lt;/li&gt;
&lt;li&gt;Gaussian CDF → GELU&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cauchy CDF → ？？？空着呢&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$x\cdot\mathrm{CDF}(x)$ 三位一体，缺的那块拼图长这样。名字是写代码的 AI 起的：&lt;strong&gt;CaLU（Cauchy Linear Unit）&lt;/strong&gt;，完整版叫 &lt;strong&gt;Leaky CaLU&lt;/strong&gt;——跟 SiLU、GELU 正好一家人，我拍板用了。门控零点斜率也排得整整齐齐：Gaussian $0.399$ &amp;gt; Cauchy $0.318$ &amp;gt; Logistic $0.25$，Cauchy 恰好卡在中间——尾部最重、开启居中，对称得很漂亮。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/04_family.svg&quot; alt=&quot;xdotathrm{gate}(x) 全家福：SiLU、CaLU、Leaky CaLU、RoCLU&quot; /&gt;&lt;/p&gt;
&lt;p&gt;名字起完去搜文献，心凉了半截又热了起来：一篇 2026 年 9 月 3 号的论文，Küçükali 等人的 &lt;strong&gt;RoCLU&lt;/strong&gt;（Robust Cauchy Linear Unit），公式是 $1.444\cdot x\cdot \ln\left(1.5 + \frac{\arctan(x)}{\pi}\right)$——同一个 $x\cdot g(\arctan(x))$ 家族，门控是个 log 变换。&lt;/p&gt;
&lt;p&gt;转念一想，这是好事：第一，说明 Cauchy 门这条路不是我一个人在走，方向大概率没错；第二，人家的门和我的门不一样（log 门 vs 精确 CDF 门），叙事不打架；第三，&lt;strong&gt;现成的文献基线&lt;/strong&gt;，白捡的。果断把它加进对比里一起跑。&lt;/p&gt;
&lt;h2&gt;实验：跑了 45 次，只看结果&lt;/h2&gt;
&lt;p&gt;实验设计很简单，核心思想就一句话：&lt;strong&gt;把模型变瘦，差距自然现形&lt;/strong&gt;。经典 LeNet 在 MNIST 上闭眼 99%，什么激活函数都一个样——所以故意把网络阉割到 $5.4\text{k}$ 参数（约为原版的 $\frac{1}{10}$），无 BN、无 Dropout、只训 $10$ 个 epoch。模型长这样（$28\times28$ 输入）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;配置&lt;/th&gt;
&lt;th&gt;输出尺寸&lt;/th&gt;
&lt;th&gt;参数量&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Conv1 + Act&lt;/td&gt;
&lt;td&gt;$1{\to}4$ 通道，$5{\times}5$&lt;/td&gt;
&lt;td&gt;$4\times24\times24$&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$4\times12\times12$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Conv2 + Act&lt;/td&gt;
&lt;td&gt;$4{\to}8$ 通道，$5{\times}5$&lt;/td&gt;
&lt;td&gt;$8\times8\times8$&lt;/td&gt;
&lt;td&gt;808&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$8\times4\times4$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Flatten&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;$128$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;FC1 + Act&lt;/td&gt;
&lt;td&gt;$128{\to}32$&lt;/td&gt;
&lt;td&gt;$32$&lt;/td&gt;
&lt;td&gt;4128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;FC2（无激活）&lt;/td&gt;
&lt;td&gt;$32{\to}10$&lt;/td&gt;
&lt;td&gt;$10$（logits）&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;合计&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5370&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;11 个激活函数，除了 Act 里装的东西不一样，其他&lt;strong&gt;严格同构&lt;/strong&gt;：同一套 Xavier 初始化、同一个优化器（Adam，$\text{lr}=10^{-3}$，$\text{weight decay}=0$，无 scheduler）、同一个 batch size（$512$）、同一个 device（mps），每个激活跑 3 个 seed。代码是 AI 搞的，我就给了超参数和 exp 编号对照表，然后训练。&lt;/p&gt;
&lt;h3&gt;主结果（MNIST 测试集，3-seed 均值 $\pm$ std，单位 %）&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;激活&lt;/th&gt;
&lt;th&gt;seed0&lt;/th&gt;
&lt;th&gt;seed1&lt;/th&gt;
&lt;th&gt;seed2&lt;/th&gt;
&lt;th&gt;均值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Soft Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.78&lt;/td&gt;
&lt;td&gt;97.39&lt;/td&gt;
&lt;td&gt;97.82&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66 \pm 0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SiLU&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.38&lt;/td&gt;
&lt;td&gt;97.83&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.63 \pm 0.23$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GELU&lt;/td&gt;
&lt;td&gt;97.58&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.71&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.61 \pm 0.09$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RoCLU&lt;/td&gt;
&lt;td&gt;97.90&lt;/td&gt;
&lt;td&gt;97.04&lt;/td&gt;
&lt;td&gt;97.69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.54 \pm 0.45$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tanh&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.37&lt;/td&gt;
&lt;td&gt;$97.52 \pm 0.15$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky CaLU&lt;/td&gt;
&lt;td&gt;97.75&lt;/td&gt;
&lt;td&gt;97.17&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.51 \pm 0.30$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CaLU&lt;/td&gt;
&lt;td&gt;97.76&lt;/td&gt;
&lt;td&gt;97.12&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.49 \pm 0.33$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arctan&lt;/td&gt;
&lt;td&gt;97.40&lt;/td&gt;
&lt;td&gt;97.70&lt;/td&gt;
&lt;td&gt;97.21&lt;/td&gt;
&lt;td&gt;$97.44 \pm 0.25$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.55&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.41 \pm 0.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReLU&lt;/td&gt;
&lt;td&gt;97.52&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.40 \pm 0.22$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sigmoid&lt;/td&gt;
&lt;td&gt;90.66&lt;/td&gt;
&lt;td&gt;94.33&lt;/td&gt;
&lt;td&gt;90.30&lt;/td&gt;
&lt;td&gt;$91.76 \pm 2.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;SiLU（$97.63$）&amp;gt; GELU（$97.61$）&amp;gt; CaLU（$97.49$）GELU 方差全场最小（$\pm 0.09$）、首 epoch 启动最快（$90.17$），高斯门不愧是工业界千锤百炼的选择，稳得可怕。RoCLU 排第四，还拿了全场最高单次 $97.90$，但方差也是除 sigmoid 外最大的。sigmoid 垫底不意外：首 epoch 只有 $43.7%$，梯度消失的教科书现场。&lt;/p&gt;
&lt;h3&gt;门控 $\times$ leak&lt;/h3&gt;
&lt;p&gt;把四个&quot;自己人&quot;单独拎出来，门控（sigmoid 门 vs Cauchy 门）$\times$ leak（$\beta=0$ vs $\beta=0.01$）正好组成对照：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;均值&lt;/th&gt;
&lt;th&gt;$\beta=0$&lt;/th&gt;
&lt;th&gt;$\beta=0.01$&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sigmoid 门&lt;/td&gt;
&lt;td&gt;SiLU 97.63&lt;/td&gt;
&lt;td&gt;Soft 97.66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cauchy 门&lt;/td&gt;
&lt;td&gt;CaLU 97.49&lt;/td&gt;
&lt;td&gt;LeakyCaLU 97.51&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;leak 效应两行都是 $+0.02\sim0.03$，门控效应两列都是 sigmoid 门 $+0.14\sim0.15$——方向完全一致，可惜幅度都在噪声带里。RoCLU（log 门）以 $97.54$ 落在两者之间，恰好对应它的门控形状也居中（$g&apos;(0)\approx 0.306$，负半轴渐近值比 Cauchy 更深：$-0.46$ vs $-0.32$）。而三位一体内部 SiLU &amp;gt; GELU &amp;gt; CaLU，恰好是门控开启锐度的&lt;strong&gt;反序&lt;/strong&gt;。但说实话，除 sigmoid 外大家差距都在噪声带里，显著性不够。&lt;/p&gt;
&lt;h3&gt;最有意思的：β 消融 vs 单调阈值&lt;/h3&gt;
&lt;p&gt;前面在 Desmos 上算过，soft leaky relu 的导数是&lt;/p&gt;
&lt;p&gt;$$f&apos;(x) = (1-\beta),[\sigma(x) + x\sigma(x)(1-\sigma(x))] + \beta$$&lt;/p&gt;
&lt;p&gt;方括号里是 SiLU 的导数，最小值约 $-0.0998$（$x\approx -2.40$ 处）。令它大于零：&lt;/p&gt;
&lt;p&gt;$$(1-\beta)(-0.0998) + \beta &amp;gt; 0 \quad\Longrightarrow\quad \beta &amp;gt; \beta^* \approx 0.0908$$&lt;/p&gt;
&lt;p&gt;所以 $\beta=0.2$ 稳单调，$\beta=0.1$ 擦边单调（最小导数 $0.01$），$\beta=0.01$ 不单调（还留着坑）。而我做了四档实验，把 $\beta$ 从 $0.01$ 放大到 $0.3$：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;$\beta$&lt;/th&gt;
&lt;th&gt;最终精度（均值$\pm$std）&lt;/th&gt;
&lt;th&gt;epoch1 均值&lt;/th&gt;
&lt;th&gt;单调性（最小导数）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.01&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66\pm0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89.92&lt;/td&gt;
&lt;td&gt;不单调（$-0.089$，留坑）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;$97.47\pm0.44$&lt;/td&gt;
&lt;td&gt;88.99&lt;/td&gt;
&lt;td&gt;擦边单调（$+0.010$）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;$97.36\pm0.46$&lt;/td&gt;
&lt;td&gt;88.02&lt;/td&gt;
&lt;td&gt;稳单调（$+0.120$）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;$97.00\pm0.41$&lt;/td&gt;
&lt;td&gt;87.30&lt;/td&gt;
&lt;td&gt;稳单调（$+0.230$）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;均值和启动速度两个维度严格单调下降，方差整体放大（$\pm0.24\to\pm0.41$，中间 $\beta=0.2$ 档最高 $\pm0.46$）。也就是说，&lt;strong&gt;单调的版本反而更差&lt;/strong&gt;。那个坑，在浅网 MNIST 上至少不是坏事。我当初&quot;把它掰直&quot;的执念，被自己的实验否了。科学就是这样，猜想负责貌美如花，数据负责铁面无私。&lt;/p&gt;
&lt;h2&gt;下一步：VGG&lt;/h2&gt;
&lt;p&gt;我的直觉是：Cauchy 门这种重尾、多项式拖尾的设计，在只有 3 个非线性层的简化 LeNet 里根本施展不开，因为门控的这种差异要连乘 8~10 次才会复利放大。下图是原因：sigmoid 门的 $1-g(x)$ 是指数衰减（半对数坐标下呈直线），三下五除二就饱和到底；而 Cauchy 门和 RoCLU 门是多项式拖尾，软得很久。层数一深，前者早早&quot;决断&quot;，后者还留着梯度（VGG 这种无 BN 的深网才是它的主场）。RoCLU 那篇论文也是往深了测（最深到 700 层）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/05_tails.svg&quot; alt=&quot;门控尾部对比（半对数坐标）：指数尾 vs 多项式尾，差两个数量级只需要 xpprox8&quot; /&gt;&lt;/p&gt;
&lt;p&gt;所以后面：VGG + CIFAR-10。门控三家（sigmoid / Cauchy-CDF / log）到底谁在深水区更能打，敬请期待下篇。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;完整实验报告、45 次训练的&lt;strong&gt;原始实验结果&lt;/strong&gt;见存档（&lt;a href=&quot;/assets/2026-10-16-desmos-and-calu/calu-archive.zip&quot;&gt;calu-archive.zip&lt;/a&gt;，共 42 个实验目录：11 个激活，3 seed，33 个主实验，另 9 个为 β 消融新增；β=0.01 一档复用主实验。解包后运行 &lt;code&gt;collect_results.py&lt;/code&gt; / &lt;code&gt;beta_ablation.py&lt;/code&gt; 即可复现全部图表），欢迎拍砖。CaLU 的名字要是跟谁撞了也请告诉我——截至动笔那天，我只查到 RoCLU 这一个亲戚。&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;参考文献&lt;/h2&gt;
&lt;p&gt;[1] Küçükali Ö, Bozkurt MH, Ulutaş E, Bozkurt I. A Novel Activation Function for Enhancing Deep Neural Network Stability and Performance. &lt;em&gt;Concurrency and Computation: Practice and Experience&lt;/em&gt;. 2026;38:e70944. DOI: &lt;a href=&quot;https://doi.org/10.1002/cpe.70944&quot;&gt;10.1002/cpe.70944&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>From Desmos to CaLU: My Activation Function Tinkering Notes (I)</title><link>https://blogs.ansonate.ink/posts/desmos-and-calu-en/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/desmos-and-calu-en/</guid><description>From a Desmos curve to the CaLU activation function: 45 controlled runs on MNIST.</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;A note on division of labor: the inspiration and conjectures are mine; the code and experiments were written by AI — I was only responsible for talking, thinking, and training.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A few days ago, tired from reviewing trigonometry, I was dragging curves around in Desmos for fun, plotting arctan and tanh on the same axes. The more I looked, the more alike they seemed — both S-shaped, both saturating on both sides. The only difference is that $\tanh$ has range $(-1, 1)$ while $\arctan$ has range $(-\frac{\pi}{2}, \frac{\pi}{2})$ — a horizontal and vertical rescaling makes them roughly coincide.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/01_tanh_arctan.svg&quot; alt=&quot;tanh vs rescaled arctan: both have slope 1 at the origin; one tail converges exponentially, the other drags polynomially&quot; /&gt;&lt;/p&gt;
&lt;p&gt;At that moment a thought popped up: &lt;strong&gt;could a rescaled arctan work directly as an activation function?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;But the thought lived for about three seconds. Because the answer is obvious: definitely not good. Just as tanh and sigmoid died in deep networks, arctan would die the same way — saturating on both sides, with derivative $\frac{1}{1+x^2}$ sliding away to nothing on either flank, so gradients vanish as soon as the network gets deep. No experiment needed; one look at the supremum of the derivative tells you. So arctan fundamentalism was abandoned on the spot.&lt;/p&gt;
&lt;p&gt;But Desmos was still open, so I stared a bit longer. I casually plotted sigmoid too.&lt;/p&gt;
&lt;h2&gt;The infamous &quot;dip&quot; in Swish&lt;/h2&gt;
&lt;p&gt;My eyes moved to Swish: $f(x) = x\cdot\sigma(x)$. The left half of this curve has a famous little dip — it bottoms out at about $-0.28$ near $x\approx -1.28$ before climbing back. Everyone says this non-monotonic dip is part of why Swish works well, but I was being stubborn at the time: &lt;strong&gt;what if I insisted on straightening it out?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/02_swish_dip.svg&quot; alt=&quot;Filling the dip: SiLU&apos;s dip, gradually flattened by soft leaky relu with different eta (eta=0.2 is already strictly monotonic)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The most handy fix is to add an x term and make it leaky:&lt;/p&gt;
&lt;p&gt;$$f(x) = (1-\beta)\cdot x\cdot\sigma(x) + \beta\cdot x$$&lt;/p&gt;
&lt;p&gt;As $x\to +\infty$ it approaches $x$, and as $x\to -\infty$ it approaches $\beta\cdot x$ — isn&apos;t that just a smoothed-out Leaky ReLU? I named it &lt;strong&gt;soft leaky relu&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Then I checked monotonicity directly in Desmos. With $\beta = 0.2$ it is solidly monotonic; with $\beta = 0.01$ the dip survives. Doing the math: $f&apos;(x) = (1-\beta)\cdot[\text{derivative of SiLU}] + \beta$, and the SiLU derivative has a minimum of $-0.0998$ at $x\approx -2.40$. Forcing the minimum derivative above zero gives &lt;strong&gt;strict monotonicity when $\beta &amp;gt; 0.0908$&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That $0.09$ threshold would turn out to be interesting later — hold that thought.&lt;/p&gt;
&lt;h2&gt;sigmoid — tanh — arctan: why not hack the gate?&lt;/h2&gt;
&lt;p&gt;After writing down soft leaky relu, I stared at it for a long time. Its structure is $x\cdot\mathrm{gate}(x) + \mathrm{leak}$. The gate is currently sigmoid, but my chain of association went like this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sigmoid and tanh are relatives&lt;/strong&gt; ($\tanh(x) = 2\sigma(2x) - 1$, second-grade knowledge), &lt;strong&gt;and tanh and arctan look alike&lt;/strong&gt; (Desmos told me) — so why not hack arctan into a gate and plug it into the same template?&lt;/p&gt;
&lt;p&gt;The original form I wrote down looked like this:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
T(x) &amp;amp;= \frac{\arctan(x)}{\pi/2} \
t(x) &amp;amp;= \frac{T(x)+1}{2} \
a(x) &amp;amp;= (1-\beta)\cdot x\cdot t(x) + \beta\cdot x
\end{align}
$$&lt;/p&gt;
&lt;p&gt;$T$ squeezes $\arctan$ into $(-1, 1)$, $t$ shifts it into $(0, 1)$ — a perfect gate. After writing it I tossed the formula to the AI, which recognized it at a glance: $\frac{1}{2}\left(\frac{2\arctan(x)}{\pi}+1\right)$ — isn&apos;t that just… the &lt;strong&gt;CDF of the standard Cauchy distribution&lt;/strong&gt;? $C(x) = \frac{1}{2} + \frac{1}{\pi}\arctan(x)$, exactly the same.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/03_gates.svg&quot; alt=&quot;Three gates side by side: sigmoid, Cauchy CDF, and RoCLU&apos;s log gate, all with range (0,1)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Following that hint, Swish and GELU all clicked together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Logistic CDF → SiLU/Swish&lt;/li&gt;
&lt;li&gt;Gaussian CDF → GELU&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cauchy CDF → ??? empty slot&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The missing piece of the $x\cdot\mathrm{CDF}(x)$ trinity looks like this. The name was coined by the coding AI: &lt;strong&gt;CaLU (Cauchy Linear Unit)&lt;/strong&gt;, the full version called &lt;strong&gt;Leaky CaLU&lt;/strong&gt; — one family with SiLU and GELU, and I approved it. The gate slopes at zero line up neatly too: Gaussian $0.399$ &amp;gt; Cauchy $0.318$ &amp;gt; Logistic $0.25$, with Cauchy sitting exactly in the middle — heaviest tails, middling turn-on, beautifully symmetric.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/04_family.svg&quot; alt=&quot;xdotathrm{gate}(x) family portrait: SiLU, CaLU, Leaky CaLU, RoCLU&quot; /&gt;&lt;/p&gt;
&lt;p&gt;After naming it I went to search the literature, and my heart sank halfway and then warmed back up: a paper from September 3, 2026 by Küçükali et al., &lt;strong&gt;RoCLU&lt;/strong&gt; (Robust Cauchy Linear Unit), with formula $1.444\cdot x\cdot \ln\left(1.5 + \frac{\arctan(x)}{\pi}\right)$ — the same $x\cdot g(\arctan(x))$ family, with a log-transformed gate.&lt;/p&gt;
&lt;p&gt;On second thought, that&apos;s good news: first, it means I&apos;m not the only one walking the Cauchy-gate path, so the direction is probably right; second, their gate and mine differ (log gate vs. exact CDF gate), so our stories don&apos;t collide; third, &lt;strong&gt;a ready-made literature baseline&lt;/strong&gt;, free for the taking. I promptly added it to the comparison runs.&lt;/p&gt;
&lt;h2&gt;Experiments: 45 runs, just the results&lt;/h2&gt;
&lt;p&gt;The experimental design is simple, with one core idea in a single sentence: &lt;strong&gt;make the model skinny and the differences reveal themselves&lt;/strong&gt;. Classic LeNet on MNIST hits 99% with eyes closed — every activation looks the same — so I deliberately crippled the network down to $5.4\text{k}$ parameters (about $\frac{1}{10}$ of the original), with no BN, no Dropout, and only $10$ training epochs. The model looks like this ($28\times28$ input):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Output size&lt;/th&gt;
&lt;th&gt;Params&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Conv1 + Act&lt;/td&gt;
&lt;td&gt;$1{\to}4$ channels, $5{\times}5$&lt;/td&gt;
&lt;td&gt;$4\times24\times24$&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$4\times12\times12$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Conv2 + Act&lt;/td&gt;
&lt;td&gt;$4{\to}8$ channels, $5{\times}5$&lt;/td&gt;
&lt;td&gt;$8\times8\times8$&lt;/td&gt;
&lt;td&gt;808&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$8\times4\times4$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Flatten&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;$128$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;FC1 + Act&lt;/td&gt;
&lt;td&gt;$128{\to}32$&lt;/td&gt;
&lt;td&gt;$32$&lt;/td&gt;
&lt;td&gt;4128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;FC2 (no activation)&lt;/td&gt;
&lt;td&gt;$32{\to}10$&lt;/td&gt;
&lt;td&gt;$10$ (logits)&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5370&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;11 activation functions; everything except what&apos;s inside Act is &lt;strong&gt;strictly identical&lt;/strong&gt;: the same Xavier initialization, the same optimizer (Adam, $\text{lr}=10^{-3}$, $\text{weight decay}=0$, no scheduler), the same batch size ($512$), the same device (mps), 3 seeds per activation. The code was done by AI — I just supplied the hyperparameters and the experiment-ID mapping table, and then did the training.&lt;/p&gt;
&lt;h3&gt;Main results (MNIST test set, 3-seed mean $\pm$ std, in %)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Activation&lt;/th&gt;
&lt;th&gt;seed0&lt;/th&gt;
&lt;th&gt;seed1&lt;/th&gt;
&lt;th&gt;seed2&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Soft Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.78&lt;/td&gt;
&lt;td&gt;97.39&lt;/td&gt;
&lt;td&gt;97.82&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66 \pm 0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SiLU&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.38&lt;/td&gt;
&lt;td&gt;97.83&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.63 \pm 0.23$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GELU&lt;/td&gt;
&lt;td&gt;97.58&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.71&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.61 \pm 0.09$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RoCLU&lt;/td&gt;
&lt;td&gt;97.90&lt;/td&gt;
&lt;td&gt;97.04&lt;/td&gt;
&lt;td&gt;97.69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.54 \pm 0.45$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tanh&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.37&lt;/td&gt;
&lt;td&gt;$97.52 \pm 0.15$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky CaLU&lt;/td&gt;
&lt;td&gt;97.75&lt;/td&gt;
&lt;td&gt;97.17&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.51 \pm 0.30$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CaLU&lt;/td&gt;
&lt;td&gt;97.76&lt;/td&gt;
&lt;td&gt;97.12&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.49 \pm 0.33$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arctan&lt;/td&gt;
&lt;td&gt;97.40&lt;/td&gt;
&lt;td&gt;97.70&lt;/td&gt;
&lt;td&gt;97.21&lt;/td&gt;
&lt;td&gt;$97.44 \pm 0.25$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.55&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.41 \pm 0.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReLU&lt;/td&gt;
&lt;td&gt;97.52&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.40 \pm 0.22$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sigmoid&lt;/td&gt;
&lt;td&gt;90.66&lt;/td&gt;
&lt;td&gt;94.33&lt;/td&gt;
&lt;td&gt;90.30&lt;/td&gt;
&lt;td&gt;$91.76 \pm 2.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;SiLU ($97.63$) &amp;gt; GELU ($97.61$) &amp;gt; CaLU ($97.49$). GELU has the smallest variance in the field ($\pm 0.09$) and the fastest epoch-1 startup ($90.17$) — the Gaussian gate, battle-tested by industry, is scarily stable. RoCLU ranks fourth and took the single best run of the field ($97.90$), but its variance is also the largest apart from sigmoid. Sigmoid finishing last is no surprise: only $43.7%$ after the first epoch, a textbook vanishing-gradient scene.&lt;/p&gt;
&lt;h3&gt;Gate $\times$ leak&lt;/h3&gt;
&lt;p&gt;Pulling out the four &quot;in-group&quot; members, gate (sigmoid gate vs. Cauchy gate) $\times$ leak ($\beta=0$ vs. $\beta=0.01$) forms a clean controlled comparison:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;$\beta=0$&lt;/th&gt;
&lt;th&gt;$\beta=0.01$&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sigmoid gate&lt;/td&gt;
&lt;td&gt;SiLU 97.63&lt;/td&gt;
&lt;td&gt;Soft 97.66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cauchy gate&lt;/td&gt;
&lt;td&gt;CaLU 97.49&lt;/td&gt;
&lt;td&gt;LeakyCaLU 97.51&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The leak effect is $+0.02\sim0.03$ in both rows, and the gate effect is sigmoid gate $+0.14\sim0.15$ in both columns — fully consistent directions, though unfortunately both within the noise band. RoCLU (log gate) lands in between at $97.54$, matching its gate shape sitting in the middle too ($g&apos;(0)\approx 0.306$, with a deeper negative-side asymptote than Cauchy: $-0.46$ vs. $-0.32$). And inside the trinity, SiLU &amp;gt; GELU &amp;gt; CaLU is exactly the &lt;strong&gt;reverse order&lt;/strong&gt; of gate turn-on sharpness. But honestly, apart from sigmoid everyone is within noise of each other — not enough significance.&lt;/p&gt;
&lt;h3&gt;The most interesting part: β ablation vs. the monotonicity threshold&lt;/h3&gt;
&lt;p&gt;As computed in Desmos earlier, the derivative of soft leaky relu is&lt;/p&gt;
&lt;p&gt;$$f&apos;(x) = (1-\beta),[\sigma(x) + x\sigma(x)(1-\sigma(x))] + \beta$$&lt;/p&gt;
&lt;p&gt;The bracket is the SiLU derivative, with minimum about $-0.0998$ (at $x\approx -2.40$). Setting it above zero:&lt;/p&gt;
&lt;p&gt;$$(1-\beta)(-0.0998) + \beta &amp;gt; 0 \quad\Longrightarrow\quad \beta &amp;gt; \beta^* \approx 0.0908$$&lt;/p&gt;
&lt;p&gt;So $\beta=0.2$ is solidly monotonic, $\beta=0.1$ is borderline monotonic (minimum derivative $0.01$), and $\beta=0.01$ is non-monotonic (keeps the dip). I ran four settings, scaling $\beta$ from $0.01$ up to $0.3$:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;$\beta$&lt;/th&gt;
&lt;th&gt;Final accuracy (mean$\pm$std)&lt;/th&gt;
&lt;th&gt;epoch-1 mean&lt;/th&gt;
&lt;th&gt;Monotonicity (min derivative)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.01&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66\pm0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89.92&lt;/td&gt;
&lt;td&gt;non-monotonic ($-0.089$, keeps dip)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;$97.47\pm0.44$&lt;/td&gt;
&lt;td&gt;88.99&lt;/td&gt;
&lt;td&gt;borderline monotonic ($+0.010$)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;$97.36\pm0.46$&lt;/td&gt;
&lt;td&gt;88.02&lt;/td&gt;
&lt;td&gt;solidly monotonic ($+0.120$)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;$97.00\pm0.41$&lt;/td&gt;
&lt;td&gt;87.30&lt;/td&gt;
&lt;td&gt;solidly monotonic ($+0.230$)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Mean and startup speed both decline strictly monotonically, while variance grows overall ($\pm0.24\to\pm0.41$, peaking at $\pm0.46$ for $\beta=0.2$). In other words, &lt;strong&gt;the monotonic versions are actually worse&lt;/strong&gt;. That dip is at least not a bad thing on shallow-network MNIST. My original obsession with &quot;straightening it out&quot; was refuted by my own experiment. That&apos;s science: conjectures get to be beautiful, data gets to be merciless.&lt;/p&gt;
&lt;h2&gt;Next: VGG&lt;/h2&gt;
&lt;p&gt;My intuition: a heavy-tailed, polynomial-dragging design like the Cauchy gate can&apos;t show its strength in a slimmed-down LeNet with only 3 nonlinear layers, because gate differences like these only compound after being multiplied 8–10 times. The figure below shows why: $1-g(x)$ of the sigmoid gate decays exponentially (a straight line on semi-log axes), saturating to the bottom in no time; while the Cauchy gate and RoCLU gate drag polynomial tails that stay soft for a long while. As depth grows, the former &quot;decides&quot; early while the latter still keeps gradients alive (a BN-free deep net like VGG is its home turf). The RoCLU paper likewise tested deep (up to 700 layers).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/05_tails.svg&quot; alt=&quot;Gate tail comparison (semi-log axes): exponential vs. polynomial tails, two orders of magnitude apart by just xpprox8&quot; /&gt;&lt;/p&gt;
&lt;p&gt;So next up: VGG + CIFAR-10. Which of the three gate families (sigmoid / Cauchy-CDF / log) fights best in deep water — stay tuned for part II.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;The full experiment report and &lt;strong&gt;raw results&lt;/strong&gt; of all 45 training runs are in the archive (&lt;a href=&quot;/assets/2026-10-16-desmos-and-calu/calu-archive.zip&quot;&gt;calu-archive.zip&lt;/a&gt;, 42 experiment directories in total: 11 activations × 3 seeds = 33 main runs, plus 9 new β-ablation runs; the β=0.01 setting reuses the main runs. Unzip and run &lt;code&gt;collect_results.py&lt;/code&gt; / &lt;code&gt;beta_ablation.py&lt;/code&gt; to reproduce all figures). Critiques welcome. And please tell me if the CaLU name collides with anyone&apos;s — as of writing, RoCLU is the only relative I found.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;p&gt;[1] Küçükali Ö, Bozkurt MH, Ulutaş E, Bozkurt I. A Novel Activation Function for Enhancing Deep Neural Network Stability and Performance. &lt;em&gt;Concurrency and Computation: Practice and Experience&lt;/em&gt;. 2026;38:e70944. DOI: &lt;a href=&quot;https://doi.org/10.1002/cpe.70944&quot;&gt;10.1002/cpe.70944&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>Who Produces Consensus? Oreskes, Marx, and the Conditions of Scientific Trust</title><link>https://blogs.ansonate.ink/posts/who-produces-consensus/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/who-produces-consensus/</guid><description>An ENGL 192 essay on Naomi Oreskes&apos; Why Trust Science and the social conditions behind scientific consensus.</description><pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;english homework&lt;/p&gt;
&lt;h1&gt;Introduction&lt;/h1&gt;
&lt;p&gt;In Naomi Oreskes&apos; &lt;em&gt;Why Trust Science&lt;/em&gt; (Oreskes, 2019), she argues that scientific knowledge is not the product of a single scientific method, but the collective consensus of a science community. It is trustworthy as it is produced by such a diverse community that each individual&apos;s bias and values can be identified and recognised, so that the knowledge in the community can be more objective.&lt;/p&gt;
&lt;p&gt;In Chapter 5 of the book, Ottmar Edenhofer and Martin Kowarsch (Edenhofer &amp;amp; Kowarsch, 2019) extend Oreskes&apos; view by adding that policy consensus does not have to be aligned with the scientific consensus. In the case of climate change, this translates to disagreements over the design of climate policies, as policy decisions require extraordinary effort to consider the policy&apos;s co-benefits and unintended side effects. So, in evaluating policies, they proposed that diversity in values and pathways is essential for effective and efficient collaboration and inclusive learning process that facilitates the evaluation.&lt;/p&gt;
&lt;p&gt;They rightly distinguish scientific consensus from policy consensus, but they do not ask who shapes the conditions where policy pluralism can operate. So the underlying question is, ‘How does this diverse consensus come from? When will the consensus degrade into a single voice or premature consensus? Who is producing this diverse consensus?’&lt;/p&gt;
&lt;p&gt;In the example of Limited Energy Theory mentioned in the book, Clarke conceptualises the bias against women&apos;s education through one of the fundamental laws of thermodynamics — the total energy is constant in a closed system. However, the human body is not a closed system, as people can eat and drink, and there was no evidence that the energy consumption involved in the process of education would lead to a decrease in the energy in the uterus. Oreskes shows that Jacobi’s critique was ignored, but she does not fully analyse the institutional conditions that allowed it to be ignored&lt;/p&gt;
&lt;p&gt;Apparently, a scientific community is not diverse because of its own characteristics or internal guidelines, but because external conditions shape it to be diverse. That is to say, diversity is not a natural property of science, nor a virtue that scientists can simply achieve through declarations of openness, but is shaped by social and political factors.&lt;/p&gt;
&lt;h1&gt;Beneath Consensus&lt;/h1&gt;
&lt;p&gt;As Karl Marx argues in &lt;em&gt;The German Ideology&lt;/em&gt; (Marx &amp;amp; Engels, 1975), the ruling ideas are always the ideas of the ruling class. The reason is simple: the one who controls the material production of society, including funds, education, publishing, and media, is also the one who controls the production of ideas, or mental production, in Marx&apos;s words, since they can decide what kind of ideas the public can perceive, which will be shaped by the ideas of the ruling entity in that field.&lt;/p&gt;
&lt;p&gt;Marx’s concept is too broad there, but it does illuminates how control over material and institutional resources shapes which ideas can become visible, credible, and consensus-forming.&lt;/p&gt;
&lt;h2&gt;Captured by Capital&lt;/h2&gt;
&lt;p&gt;Consider the example of climate change. Large corporations and institutions have the ability and motivation to promote skepticism on the topic of climate change, not only because they fund research that harms their interests, but also because they control the means of mental production.&lt;/p&gt;
&lt;p&gt;They control funding and investment, which allows them to select which questions are asked and studied by shifting resources toward preferred topics. They influence publishing and media, allowing them to support think tanks that manufacture doubt, buy media attention to amplify skepticism, lobby governments to block policy, and pressure publishers and journals to present ‘both sides’ of the ambitious climate policy even if the science were unsettled. None of this requires fabricating data but only shaping the ground where consensus is produced.&lt;/p&gt;
&lt;p&gt;Whoever rules topics generates consensus that the public can perceive. Therefore we cannot rely solely on discussion, as Edenhofer and Kowarsch propose. We must carefully examine whose ideas are recognised in that discussion — in other words, whether the backgrounds of the scientists, or ‘cartographers’ mentioned in that chapter, are diverse not only in their fields of study but also in the industries they depend on for funding and career advancement. In this case, scientists are the minority because the system fails to support the scientific community in a manner independent of the corporations and institutes that profit from the fossil fuel industry.&lt;/p&gt;
&lt;p&gt;In the case of tobacco, the same mechanism applies. Of course, the scientific consensus that tobacco harms health was eventually established, but the point is that it delayed the consensus for decades, and the delay itself caused enormous harm to public health. This is not an issue inside the scientific community. A single sponsor or political entity that science serves in a particular field can monopolise the means of mental production, which converges science into a premature consensus.&lt;/p&gt;
&lt;h2&gt;On Political And Social Factors&lt;/h2&gt;
&lt;p&gt;Besides capital, sometimes scientific consensus can be corrupted by politics. In the example of many American scientists opposing continental drift theory, they accuse the theory of being a flawed hypothetico-deductive European theory reflecting authoritativeness instead of a theory built upon inductive methods mirroring American pluralism and democracy. However, this accusation reveals the institutional power of the accusers more than the issues in Wegener&apos;s theory of continental drift. As the leading figures in geology, they controlled the means of mental production in geological research by defining preferred methods, important topics, and standards of reliable conclusions. By equating induction with democracy and deduction with authoritarianism, they turned a political preference into a methodological criterion. In this way, Wegener&apos;s theory was excluded from discussion simply because it did not meet that criterion. In this case, it is a science community itself that monopolised the authority to decide what counts as science.&lt;/p&gt;
&lt;p&gt;In the example of eugenics, many believed that human beings can be improved by socially controlling reproduction. That is, to encourage ‘fit’ families that commonly are wealthy and white, and to discourage the reproduction of ‘unfit’ families that commonly associate with illness and poverty. This theory was convincing because it was framed and justified as a logical deduction from scientific theories, including Darwin&apos;s theory of evolution. Diversity seemed to exist as people and institutions supported eugenics, including writers, presidents, the Supreme Court and great figures in Eugenics Record Office (ERO). But really? These people and institutions are tied together to marginalise the dissenting voices. Diversity only existed at the organisational level, but in terms of ideology and interests except science, these entities are highly homogeneous and deeply aligned. Like what happened in the case of climate change, this alliance monopolised the means of mental production, such as funds and authority to decide what the problem is, like why eugenics is reasonable and beneficial, instead of questioning its validity and ethical compliance.&lt;/p&gt;
&lt;p&gt;The public&apos;s view is equally important. Eugenics was also embraced by many ordinary American people because Eugenics resonated with racial anxiety and dissatisfaction with immigration in their minds. The same fears that immigrants brought crime and disease exacerbated immigration restrictions and eventually led to the Eugenics policy. What makes it worse is that many members of the public lacked specialised training to distinguish between genuine genetic science and racist theories ‘framed as applications of biological science’. The alliance of capital, state, and scientific authority took advantage of it and thus controlled not only the means of mental production but also the emotional and ideological ground where consensus was built.&lt;/p&gt;
&lt;p&gt;Together, climate change illustrates a top-down dynamic where capital controls funding, publishing, media, and think tanks, and thereby rules research agendas and shapes consensus. In contrast, eugenics includes a bottom-up dynamic where political movements leverage racial anxieties, fears regarding immigration, and the limitations of public understanding of science to foster public acceptance of eugenics.&lt;/p&gt;
&lt;p&gt;To conclude, the diversity of science is built upon the plurality in the parties that control the means of mental production. That is, no single party decides where to direct funds, what the problem is and what the appropriate method is.&lt;/p&gt;
&lt;h1&gt;Why trust science?&lt;/h1&gt;
&lt;p&gt;Oreskes is right that science is trustworthy when it is the critical consensus of a diverse community. But her prescription, including diversity, openness, and criticality, presupposes the condition that it aims to produce. Instead of a norm that scientists can will into existence, it is an institutional achievement that depends on the plural control of the means of mental production.&lt;/p&gt;
&lt;p&gt;Trusting science should not be a faith for the public, otherwise the tragedy of eugenics would repeat itself on other topics in the future. Trust should be based on a prerequisite that the parties generating consensus must be plural, because they control the means of mental production that shape the diversity that is essential to reliable knowledge.&lt;/p&gt;
&lt;p&gt;With this condition met, we trust science because of its self-correcting ability, an ability provided by and dependent on its political and social environment. But we cannot wait for certainty and correction before acting. This is Oreskes&apos; Pascal&apos;s Wager, ‘if we act on scientific knowledge and it turns out to be wrong, we may have created a better world for nothing; if we fail to act and it turns out to be right, people will suffer, and the world will be diminished’.&lt;/p&gt;
&lt;p&gt;Therefore, in the darkness before the dawn of correction, we trust science not because it is reliable but because it is fallible and self-correctable. And most importantly, it is the best available way to reduce the harm we do to others and to ourselves. In this way, this trust is conditional, institutional, and fragile since it depends on the plural control of the means of mental production, and most importantly, on our willingness to maintain it.&lt;/p&gt;
&lt;h1&gt;References&lt;/h1&gt;
&lt;p&gt;Edenhofer, O., &amp;amp; Kowarsch, M. (2019). Pascal&apos;s Wager Reframed: Toward Trustworthy Climate Policy Assessments for Risk Societies. In N. Oreskes (Ed.), &lt;em&gt;Why Trust Science?&lt;/em&gt; (pp. 191–201). Princeton University Press.&lt;/p&gt;
&lt;p&gt;Marx, K., &amp;amp; Engels, F. (1975). &lt;em&gt;The German Ideology&lt;/em&gt;. International Publishers. (Written Fall 1845 to mid-1846. First published in full 1932.)&lt;/p&gt;
&lt;p&gt;Oreskes, N. (2019). &lt;em&gt;Why Trust Science?&lt;/em&gt; Princeton University Press.&lt;/p&gt;
</content:encoded></item><item><title>Back to the Future: Why I Build Modern Web Apps with htmx, Template Engines, and Tailwind</title><link>https://blogs.ansonate.ink/posts/htmx-tailwind-en/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/htmx-tailwind-en/</guid><description>Why I build modern web apps with htmx, server templates, and Tailwind: forms, error handling, SSE, and islands.</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;A story about why &quot;retro tools&quot; still hold up today.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;1. A Feeling of Déjà Vu&lt;/h2&gt;
&lt;p&gt;Recently I took on a real-time data dashboard. It needs user login, tiered permissions (admins, volunteers, and clients each seeing their own views), a live card grid refreshing every few seconds, and a pile of dense form operations. Sounds like a typical 2026 project.&lt;/p&gt;
&lt;p&gt;My first reaction was the typical 2026 reaction too: React plus React Router, React Query for server state, Zustand for client state, some form library, some CSS-in-JS solution, plus a WebSocket client. I got as far as the &lt;code&gt;npm create&lt;/code&gt; step — and stopped.&lt;/p&gt;
&lt;p&gt;Because a solution from 20 years ago suddenly flashed through my mind. Back then, pages came from the server, forms were real &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;s, and errors were real HTTP status codes. The user submits, the page navigates — that simple. Of course, that era had its own hell — ViewState, postbacks, layers of unpredictably nested server controls — so I&apos;m not nostalgic for it. What I miss is that &lt;strong&gt;mental model&lt;/strong&gt;: the server understands the page, there is exactly one state, and HTML is a projection of that state.&lt;/p&gt;
&lt;p&gt;Then I realized: over the past decade-plus we didn&apos;t &quot;abandon&quot; that model — we were &lt;strong&gt;forced&lt;/strong&gt; away from it by what browsers couldn&apos;t do yet. Browsers back then only had crude full-page navigation — XHR existed, but mature, declarative partial updates didn&apos;t — so we invented client-side routing, client-side state, and client-side rendering, stuffing an entire application runtime into the browser just to avoid the primitiveness of &quot;reloading the whole page on every interaction.&quot;&lt;/p&gt;
&lt;p&gt;And today, htmx has patched that hole.&lt;/p&gt;
&lt;p&gt;htmx isn&apos;t retro. htmx fulfills the promise HTML never delivered back then: letting &lt;strong&gt;any element&lt;/strong&gt; fire a request, letting response HTML &lt;strong&gt;replace a target fragment&lt;/strong&gt;, letting the server push &lt;strong&gt;partial updates&lt;/strong&gt; instead of full-page refreshes. You don&apos;t need a heavyweight framework for it — you just add a few attributes to ordinary HTML.&lt;/p&gt;
&lt;p&gt;What this post wants to explain is a design pattern: &lt;strong&gt;why not build an SPA, why the htmx + template engine + Tailwind combo works, and how login/logout, error handling, live updates, and interactive components each land in practice&lt;/strong&gt;. It&apos;s language-agnostic — I used Rust and axum, but swap in Django, Rails, Phoenix, or ASP.NET and the argument still holds.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Why Not Build an SPA&lt;/h2&gt;
&lt;p&gt;I don&apos;t want to argue &quot;SPAs are bad&quot; — they&apos;re not; in many scenarios they&apos;re the right answer. My point is: &lt;strong&gt;in this application, I pay twice for every layer of abstraction an SPA brings.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;The Twins of State&lt;/h3&gt;
&lt;p&gt;This is the most fundamental one. In an SPA, you always maintain two copies of state: the true state on the server, and a mirror state in the browser. A user has 100 points — those 100 points live in the database, and also in some React component tree&apos;s state. To keep the two consistent, you need cache invalidation, query keys, optimistic updates, revalidation — a whole set of dedicated libraries and mental overhead to solve a synchronization problem &lt;strong&gt;you created yourself&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In a server-rendered app, there is only one state. The page is a projection of the state, nothing more. The server says &quot;this card&apos;s state is now X,&quot; and the browser displays X. There is no second copy of state to synchronize, because there never was a second copy.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    subgraph SPA[&quot;SPA — two copies of state&quot;]
        direction TD
        A[&quot;Server state&quot;] &amp;lt;--&amp;gt;|&quot;Sync: cache invalidation / query keys / optimistic updates&quot;| B[&quot;Client mirror state&quot;]
    end
    subgraph HTMX[&quot;htmx — one state&quot;]
        direction TD
        C[&quot;Server state&quot;] --&amp;gt;|&quot;Render HTML&quot;| D[&quot;Page&quot;]
    end
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Twins of Routing&lt;/h3&gt;
&lt;p&gt;An SPA has two routing systems: client-side routing (the user clicks a link, the address bar changes, React Router decides which component to render) and server-side API routing (the endpoints the client fetches data from). Both routing tables need their own permission checks, their own error shapes, their own loading states. For an &quot;admins only&quot; page, you guard on the frontend (don&apos;t render) &lt;em&gt;and&lt;/em&gt; on the backend (don&apos;t return the data) — because the frontend guard can always be bypassed.&lt;/p&gt;
&lt;p&gt;A server-rendered app has exactly one routing table. Permissions are checked in one place, pages are assembled in one place, links are real links, and navigation is real HTTP requests.&lt;/p&gt;
&lt;h3&gt;Build Cost&lt;/h3&gt;
&lt;p&gt;An SPA frontend is an independent production line: bundling pipeline, tree shaking, code splitting, type synchronization (frontend TypeScript types aligned with backend DTOs), API version negotiation. The essence of all this work is: &lt;strong&gt;moving HTML away from the server, then moving data back.&lt;/strong&gt; If your goal was always &quot;the server owns state, the browser displays state,&quot; what are you moving things around for?&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An SPA treats the browser as an application runtime; htmx treats the browser as an HTML renderer. The former needs a framework; the latter just needs a web framework.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;To be fair, I should be clear about when an SPA is still the right call: offline-first apps, complex client-side state machines (e.g. an editor with dozens of drag interactions), rich-text/collaborative editing, and products where &quot;the page itself is a local application.&quot; In those cases the client really is a runtime, not a renderer. But my dashboard isn&apos;t one of them.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. htmx + Template Engine + Tailwind: Why the Trio Works&lt;/h2&gt;
&lt;p&gt;Looked at alone, none of these three tools is remarkable. Their power comes from the combination.&lt;/p&gt;
&lt;h3&gt;Template Engines: Components on the Server&lt;/h3&gt;
&lt;p&gt;A template engine (Askama, Jinja, ERB, Tera — pick your favorite) gives you compile-time type checking and automatic HTML escaping, and more importantly: it lets you &lt;strong&gt;assemble fragments on the server&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;There&apos;s a key insight here: in an SPA you write a &lt;code&gt;&amp;lt;Component&amp;gt;&lt;/code&gt; that returns JSX; in htmx you write a template function that returns a chunk of HTML. &lt;strong&gt;Same granularity, but the platform is now HTTP.&lt;/strong&gt; A &quot;card component&quot; is just a template; a handler calls it, fills in the data, and returns HTML.&lt;/p&gt;
&lt;h3&gt;htmx: Declarative Requests and Replacement&lt;/h3&gt;
&lt;p&gt;htmx turns &quot;fire a request, swap the content&quot; into a few HTML attributes. Here&apos;s the most typical example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form hx-post=&quot;/sessions/42/assignments&quot;
      hx-target=&quot;#card-17&quot;
      hx-swap=&quot;outerHTML&quot;&amp;gt;
    &amp;lt;input name=&quot;exercise&quot; placeholder=&quot;exercise&quot; required&amp;gt;
    &amp;lt;button&amp;gt;Assign&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The user clicks the button, htmx intercepts the submit and POSTs to the server. The server returns a &lt;strong&gt;new card HTML&lt;/strong&gt; — the same card as before, but with updated content. htmx swaps that HTML into the &lt;code&gt;#card-17&lt;/code&gt; slot. No JSON, no client store, no re-render scheduling, no diffing algorithm. The entire &quot;update one card&quot; interaction is three attributes plus one server-side template function.&lt;/p&gt;
&lt;h3&gt;Tailwind: The Template Is the Component&lt;/h3&gt;
&lt;p&gt;Because styles live in the template, &lt;strong&gt;the template becomes the component&lt;/strong&gt;. Every fragment carries its own styling; you no longer jump between CSS files, JS files, and component files. To restyle a card, you edit the template that renders that card.&lt;/p&gt;
&lt;p&gt;And utility classes pay off infinitely here — they let you barely write CSS at all. No semantic class names, no BEM naming schemes, no selector hierarchies; &lt;code&gt;flex&lt;/code&gt;, &lt;code&gt;gap-3&lt;/code&gt;, &lt;code&gt;text-sm&lt;/code&gt;, &lt;code&gt;font-mono&lt;/code&gt; go straight on the element. Elsewhere that&apos;s just &quot;fewer keystrokes&quot;; but in the template world, your &quot;component&quot; is a server-side fragment, and the styling lives inside the fragment, rendered and swapped along with it. A card arrives from the server with its styles already complete; restyling a card means editing that card&apos;s template. &lt;strong&gt;A fragment is self-contained&lt;/strong&gt; — structure, behavior, and styling in the same place. You no longer maintain a mental map of &quot;where this class is defined and at which layer it gets overridden,&quot; which is far less mental overhead.&lt;/p&gt;
&lt;p&gt;Even better is Tailwind&apos;s build model. It scans your template files at build time and compiles a static CSS file. The browser receives &lt;strong&gt;one&lt;/strong&gt; fingerprinted, cacheable &lt;code&gt;app.css&lt;/code&gt;. No runtime CSS-in-JS, no cost of injecting &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; at runtime, no hydration. Styling is fully resolved at build time; the runtime stays clean.&lt;/p&gt;
&lt;h3&gt;Modern SSR: Tearing Down the House to Repaint a Wall&lt;/h3&gt;
&lt;p&gt;Someone might object: don&apos;t Next.js and Remix do server rendering too?&lt;/p&gt;
&lt;p&gt;Of course they do. But their approach is &lt;strong&gt;wanting both the framework and SSR, then stitching the two together&lt;/strong&gt;: you still write React/Vue components, maintain client state, and run a full bundling pipeline, and then the framework &quot;hydrates&quot; all of that onto the server-rendered HTML. Hydration itself is a cost; so Server Components, island partitioning, and similar mechanisms appear to &lt;strong&gt;reduce&lt;/strong&gt; hydration — but the layers stack up, with more and more boundaries and rules, all to reconcile the natural tension between &quot;client framework&quot; and &quot;server rendering.&quot;&lt;/p&gt;
&lt;p&gt;That&apos;s what I mean by &quot;tearing down the house to repaint a wall.&quot; You want a faster first paint and better SEO, and end up importing an entire hydration mechanism to rescue a client runtime &lt;strong&gt;that could have not existed at all&lt;/strong&gt;. htmx takes the other road: &lt;strong&gt;no client framework at all&lt;/strong&gt;, so &quot;hydration&quot; simply never comes up — HTML arrives from the server in its final form, with no second initialization and no handoff between two copies of state.&lt;/p&gt;
&lt;p&gt;(Astro is the most clear-headed of that family: zero JS by default, islands mounted on demand. But it still asks you to embrace component frameworks and build chains; htmx&apos;s answer is more thorough — not even the component framework.)&lt;/p&gt;
&lt;h3&gt;What&apos;s Retro Is the Mental Model, Not the Tools&lt;/h3&gt;
&lt;p&gt;This stack does feel like aspx and php at times. Because the ASPX and PHP generation wasn&apos;t dumb — they were right: &lt;strong&gt;the server owns state, HTML owns expression.&lt;/strong&gt; They were just dragged down by their tools: no type-safe templates, no declarative partial updates, no genuinely usable CSS system, so they hand-wrote &lt;code&gt;Response.Write&lt;/code&gt; and &lt;code&gt;echo&lt;/code&gt; and struggled in the mud of full-page postbacks.&lt;/p&gt;
&lt;p&gt;Today we use the same mental model, but with the tools 2026 deserves: type-safe templates, attribute-driven partial updates, build-time-compiled CSS. &lt;strong&gt;What&apos;s retro isn&apos;t the tools — it&apos;s the idea that &quot;the server understands the page.&quot; And it was never wrong.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These ideas aren&apos;t mine — Carson Gross&apos;s &lt;em&gt;Hypermedia Systems&lt;/em&gt; and the essays section of the htmx site explained &quot;hypermedia as the engine of application state&quot; (HATEOAS) far more thoroughly than I can. What I do here is just land those principles in a real project and honestly record where things went smoothly and where I had to supplement with JavaScript. Further reading is linked at the end.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Login and Logout: When a Form Becomes a Real Form Again&lt;/h2&gt;
&lt;p&gt;Login is where &quot;traditional Web&quot; and SPA diverge most — and the best illustration of &quot;when to use htmx and when not to.&quot;&lt;/p&gt;
&lt;h3&gt;A Counterintuitive Decision: Deliberately Not Using htmx for the Login Form&lt;/h3&gt;
&lt;p&gt;My login form is a plain &lt;code&gt;&amp;lt;form method=&quot;post&quot;&amp;gt;&lt;/code&gt; with no &lt;code&gt;hx-*&lt;/code&gt; attributes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form method=&quot;post&quot; action=&quot;/login&quot; id=&quot;login-form&quot;&amp;gt;
    &amp;lt;label&amp;gt;Username
        &amp;lt;input name=&quot;username&quot; autocomplete=&quot;username&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;label&amp;gt;Password
        &amp;lt;input name=&quot;password&quot; type=&quot;password&quot; autocomplete=&quot;current-password&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;button type=&quot;submit&quot;&amp;gt;Log in&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why? Because what happens after a successful login — writing the session, redirecting to the dashboard — &lt;strong&gt;is naturally a full page navigation&lt;/strong&gt;, not a partial fragment swap. Login isn&apos;t a &quot;swap this HTML for that HTML&quot; operation; it&apos;s an identity transition from &quot;anonymous&quot; to &quot;logged in.&quot; Once the transition completes, the whole page should re-render, not swap one small fragment.&lt;/p&gt;
&lt;p&gt;The more practical reasons come from browser behavior. A real form submission lets password managers correctly handle &lt;code&gt;autocomplete=&quot;current-password&quot;&lt;/code&gt; autofill, and lets the browser recognize &quot;this is a login form.&quot; And — crucial for login — &lt;strong&gt;an ordinary &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; still works with no JavaScript, or before htmx has finished loading&lt;/strong&gt;. Login is the first door into your system; you don&apos;t want that door to depend on a just-loaded JS library to open.&lt;/p&gt;
&lt;p&gt;So the login form uses &lt;strong&gt;real navigation&lt;/strong&gt;. &lt;strong&gt;htmx is not &quot;htmx everywhere&quot; — it&apos;s &quot;htmx where it belongs.&quot;&lt;/strong&gt; For login, the traditional form is the righter tool.&lt;/p&gt;
&lt;h3&gt;One Endpoint, Two Response Shapes&lt;/h3&gt;
&lt;p&gt;But here&apos;s the catch: the same &lt;code&gt;/login&lt;/code&gt; endpoint may be hit by browser navigation or by htmx (if you triggered it via htmx elsewhere). How does the server tell them apart?&lt;/p&gt;
&lt;p&gt;The answer is the &lt;code&gt;HX-Request&lt;/code&gt; header. htmx includes &lt;code&gt;HX-Request: true&lt;/code&gt; on every request it sends, and the server branches on it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def login_redirect(headers, dest, cookie):
    if is_htmx(headers):
        # htmx call: 200 + HX-Redirect, let the browser do a real navigation
        return Response(200, {&quot;HX-Redirect&quot;: dest, &quot;Set-Cookie&quot;: cookie})
    else:
        # Ordinary browser navigation: classic 303
        return Response(303, {&quot;Location&quot;: dest, &quot;Set-Cookie&quot;: cookie})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both sides end in the same real navigation; only the trigger differs. This branching pattern — &lt;strong&gt;deciding the response shape from &lt;code&gt;HX-Request&lt;/code&gt;&lt;/strong&gt; — runs through the entire error-handling chapter and is one of the cornerstones of this architecture.&lt;/p&gt;
&lt;h3&gt;Tiered Permissions: Extractors, Not Middleware&lt;/h3&gt;
&lt;p&gt;After login comes authorization. This app has three roles seeing completely different pages. My approach is to make &quot;who this is and what role they have&quot; a request extractor, with each route declaring the identity it needs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AdminPageAuth:
    # Resolve &quot;admin identity&quot; from the request, for full-page routes

    def from_request(state, request):
        match AdminAuth.from_request(state, request):
            case Ok(auth):
                return Ok(auth)
            case Err(Unauthorized(_)):
                # Unauthenticated page visit: 303 to /login, not a JSON 401
                return Err(Response(303, {&quot;Location&quot;: &quot;/login&quot;}))
            case Err(e):
                return Err(e.into_response())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The same &quot;unauthorized&quot; has two response shapes&lt;/strong&gt;: when a browser visits a page, it needs to be navigated to the login form; when an API client calls, it needs a &lt;code&gt;401&lt;/code&gt; plus JSON. The reason is simple — the browser needs a page, the client needs structured data. The extractor does the right thing here: unauthorized on full-page routes becomes a 303, unauthorized on API routes becomes a 401 JSON, both derived from the same &lt;code&gt;Unauthorized&lt;/code&gt; error.&lt;/p&gt;
&lt;p&gt;Wrong role is a different matter. A volunteer visiting an admin page deserves not &quot;please log in&quot; but &lt;code&gt;403&lt;/code&gt; — you&apos;re already logged in, you just lack permission. That distinction (401 unauthenticated vs. 403 unauthorized) gets blurred together in many systems; here it&apos;s kept explicit.&lt;/p&gt;
&lt;h3&gt;Logout: That&apos;s All There Is&lt;/h3&gt;
&lt;p&gt;Logout is a POST: the server revokes the token, clears the cookie, and 303s back to the login page.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def logout(state, request):
    if session_cookie(request):
        revoke_token(session_cookie(request))
    return Response(303, {&quot;Location&quot;: &quot;/login&quot;, &quot;Set-Cookie&quot;: clear_session_cookie()})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it. No &lt;code&gt;localStorage.removeItem(&apos;jwt&apos;)&lt;/code&gt;, no Redux reset, no &quot;clear all client-side caches&quot; ceremony. Because state lived on the server all along, logout is deleting that session record on the server and clearing the cookie in the browser.&lt;/p&gt;
&lt;h3&gt;Security Details That Emerge Naturally&lt;/h3&gt;
&lt;p&gt;These details are utterly natural in the &quot;traditional form&quot; world, yet become awkward in the SPA + JWT world:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HttpOnly cookies&lt;/strong&gt;: the session token sits in an HttpOnly cookie that JavaScript can&apos;t read at all — XSS can&apos;t steal it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Password hashing on a background thread&lt;/strong&gt;: argon2-style hashing takes ~half a second and must never block the event loop — throw it into a background task.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limiting by (username, IP)&lt;/strong&gt;: against brute force, with the limiter gating before the hash runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explicitly rendered CAPTCHA&lt;/strong&gt;: because login is a pure form (real navigation), the CAPTCHA widget is freshly rendered each time with single-use tokens — which fits exactly with the &quot;re-render the login page on every failure&quot; flow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these is an htmx-exclusive feature, but they all become &lt;strong&gt;natural again once you&apos;re back in the real-form model&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;CSRF: An Old Threat with an Old Fix&lt;/h3&gt;
&lt;p&gt;Authenticating with session cookies means facing an old friend again: CSRF. An attacker lures the user&apos;s browser into firing a state-changing request at your site without the user knowing — because you use cookies, the browser attaches them automatically.&lt;/p&gt;
&lt;p&gt;In the SPA + JWT world, this problem is mostly sidestepped: the token lives in localStorage and JavaScript manually stuffs it into headers, which the browser never sends automatically. But note this only moves the risk — XSS can now steal that token outright. On the cookie + HttpOnly path, the token is unreadable to JavaScript, so XSS can&apos;t steal it — though XSS can still operate on the victim&apos;s logged-in session as a proxy, just without carrying the credential away. The price is handling CSRF head-on.&lt;/p&gt;
&lt;p&gt;The good news: the CSRF fix is old, mature, and lands especially naturally because forms are server-rendered:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SameSite&lt;/code&gt; attribute&lt;/strong&gt;: mark the cookie &lt;code&gt;SameSite=Lax&lt;/code&gt; (the modern browser default) or &lt;code&gt;Strict&lt;/code&gt;, and browsers won&apos;t attach it on cross-site requests — already blocking the vast majority of CSRF.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRF tokens&lt;/strong&gt;: a per-session secret the server writes into a hidden field when rendering the form, verified on submit. Because rendering is server-side, stuffing it into the field is one template variable — no client logic needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So this is another place where &quot;back in the traditional-form world, things get easier&quot;: the threat is old, the fix is old, and both have been proven over decades.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Error Handling: htmx&apos;s Deepest Pit, and Its Most Valuable Contract&lt;/h2&gt;
&lt;p&gt;If you take one thing from this post, make it this section. Because htmx error handling hides a deeply counterintuitive pit — one that makes your app &lt;strong&gt;silently fail&lt;/strong&gt; at certain moments, with the user seeing only a button that seemingly does nothing. Once you understand the problem, you also understand the most elegant part of this architecture: errors stop being chores scattered across the frontend and become a contract that lives in one function, shared by every endpoint.&lt;/p&gt;
&lt;h3&gt;The Pit: htmx Only Swaps Successful Responses&lt;/h3&gt;
&lt;p&gt;Picture the most ordinary scenario. There&apos;s a card on the page with a form; the user clicks &quot;Confirm&quot; and expects the card to update. In an SPA, your &lt;code&gt;fetch&lt;/code&gt; returns a Promise — success or failure, you can do something in &lt;code&gt;.catch&lt;/code&gt;. In htmx, the flow is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;htmx intercepts the form submit and sends an XHR request to the server.&lt;/li&gt;
&lt;li&gt;The server returns a response.&lt;/li&gt;
&lt;li&gt;htmx replaces your target element with the response body.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step three is the crux. htmx&apos;s default behavior: &lt;strong&gt;only 2xx responses get swapped into the page&lt;/strong&gt;. If your endpoint returns a 400 or 500, htmx fires an &lt;code&gt;htmx:responseError&lt;/code&gt; event and then… does nothing. The original DOM stays untouched; your error message sits in the response body, visible only in the browser devtools network panel.&lt;/p&gt;
&lt;p&gt;For a &quot;user clicked a button and stares at the screen waiting for feedback&quot; form, that&apos;s catastrophic. No red hint, no toast, not even a console error. The user assumes the button is broken and clicks again, and again — the server correctly rejects the request every time, but every rejection vanishes into thin air.&lt;/p&gt;
&lt;p&gt;The root cause: &lt;strong&gt;htmx treats &quot;errors&quot; as something that shouldn&apos;t be shown to the user by default&lt;/strong&gt;. That default suits &quot;background poll failed, retry quietly&quot; scenarios, but completely fails &quot;the user mistyped a field&quot; scenarios.&lt;/p&gt;
&lt;h3&gt;The Fix: Dual-Shape Responses&lt;/h3&gt;
&lt;p&gt;So I set myself one rule covering all htmx fragment endpoints:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For htmx callers, always return 200 and put the outcome — success or failure — in the body; for everyone else, return the real status code.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Unpacked, the rule has two branches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On success&lt;/strong&gt;, return 200 with the fresh replacement HTML in the body, plus an &lt;code&gt;HX-Trigger&lt;/code&gt; header telling the browser to pop a success toast. The user sees the card change, with a &quot;saved&quot; flash in the corner.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On failure&lt;/strong&gt;, still return 200, but with an &quot;something went wrong&quot; hint in the body and an &lt;code&gt;HX-Reswap: none&lt;/code&gt; header telling htmx &lt;strong&gt;not to touch the target element&lt;/strong&gt;. The card isn&apos;t wiped, the user&apos;s input stays, and the error is written separately into the page&apos;s toast container via an out-of-band (OOB) patch.&lt;/p&gt;
&lt;p&gt;And &lt;strong&gt;non-htmx callers&lt;/strong&gt; — browsers without JavaScript, direct API clients, test code — should never know toasts exist. They want an honest HTTP status code and a structured JSON error body.&lt;/p&gt;
&lt;p&gt;The core of the whole mechanism is converging the &quot;how to present errors&quot; decision into one function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def form_err(headers, error):
    if is_htmx(headers):
        # htmx fragment request: always 200, keep the original DOM, errors go via toast
        return Response(
            status=200,
            headers={
                &quot;HX-Trigger&quot;: toast_trigger(error.message, &quot;error&quot;),
                &quot;HX-Reswap&quot;: &quot;none&quot;,
            },
            body=toast_oob(error.message, &quot;error&quot;),   # an OOB #toast HTML chunk
        )
    else:
        # Ordinary callers: real status code + JSON error body
        return error.into_response()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 200 on the failure branch is &lt;strong&gt;deliberate&lt;/strong&gt;, not lazy. Precisely because htmx only swaps 2xx, we must disguise &quot;failure&quot; as a &quot;successfully fetched response&quot; so htmx is willing to hand it to the page. The real failure information travels via the &lt;code&gt;HX-Trigger&lt;/code&gt; header, not the status code.&lt;/p&gt;
&lt;h3&gt;A Possible Objection: Does 200 Violate HTTP Semantics?&lt;/h3&gt;
&lt;p&gt;Seeing the &quot;always return 200&quot; rule, someone will frown: isn&apos;t that lying? An error is an error — why wrap it in 200?&lt;/p&gt;
&lt;p&gt;That conflates two different things. The key question: &lt;strong&gt;what is the &quot;resource&quot; this request asks for?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For an htmx fragment request, what the client wants isn&apos;t the fact of &quot;whether this operation succeeded,&quot; but a chunk of &lt;strong&gt;HTML that fits into that div&lt;/strong&gt;. The server did successfully produce that HTML — it just happens to render an error message. From that angle, 200 is fully honest: the request succeeded, and the body is &quot;rendered error DOM.&quot; The real business error (&quot;a field was filled in wrong&quot;) travels in-band via &lt;code&gt;HX-Trigger&lt;/code&gt; (the toast message).&lt;/p&gt;
&lt;p&gt;And where status-code semantics truly matter — API clients, no-JS browsers, tests, monitoring — we &lt;strong&gt;didn&apos;t&lt;/strong&gt; break them: those callers take the other branch and get real 400/401/422 codes plus JSON. HTTP semantics are fully preserved where they should apply, relaxed only in the one case of &quot;the client explicitly wants an HTML fragment.&quot;&lt;/p&gt;
&lt;p&gt;To be precise: this is &lt;strong&gt;not&lt;/strong&gt; claiming HTTP semantics are void — 404, 409, 422 and friends exist for business outcomes and are still used as usual in the error enum. We&apos;re just making one pragmatic concession for &quot;the client wants an HTML fragment&quot;: htmx only swaps 2xx (unless you tweak &lt;code&gt;htmx.config.responseHandling&lt;/code&gt;), so &quot;the business operation failed&quot; goes in the body rather than the status code. An equally reasonable alternative is listening for &lt;code&gt;htmx:responseError&lt;/code&gt; and swapping real 4xx bodies in; we just picked the plainer road.&lt;/p&gt;
&lt;h3&gt;An Unexpected Detail: Latin-1 Headers&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;HX-Trigger&lt;/code&gt; header looks simple but has a bite waiting — one that only shows on non-English content.&lt;/p&gt;
&lt;p&gt;Browsers send requests via XHR underneath, and response headers XHR receives get decoded as &lt;strong&gt;Latin-1 (ISO-8859-1)&lt;/strong&gt;. So if you stuff UTF-8 text into &lt;code&gt;HX-Trigger&lt;/code&gt; — a Chinese hint, say, or an en-dash — it arrives at the browser as mojibake. A &lt;code&gt;Zone 60–120 bpm&lt;/code&gt; becomes &lt;code&gt;Zone 60â120 bpm&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I had no idea at first, until a toast containing non-ASCII characters rendered garbled in the browser. After a good while debugging I located it: my server encoding wasn&apos;t wrong — the response-header channel itself can only safely carry ASCII.&lt;/p&gt;
&lt;p&gt;The fix is direct: after serializing the &lt;code&gt;HX-Trigger&lt;/code&gt; JSON, escape every non-ASCII character into &lt;code&gt;\uXXXX&lt;/code&gt; form. The structural JSON characters (quotes, colons, braces) are ASCII anyway, so the conversion is safe; and the client restores the true characters automatically with &lt;code&gt;JSON.parse&lt;/code&gt;. Mojibake gone; what the server sends and what the client parses match exactly.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Server side: escape every non-ASCII character as \uXXXX
def toast_trigger(message, kind):
    payload = json.dumps({&quot;toast&quot;: {&quot;msg&quot;: message, &quot;kind&quot;: kind}})
    return ascii_safe(payload)   # &quot;Zone 60\u2013120 bpm \u00b7 done&quot;

# Client side: JSON.parse restores automatically
# &quot;Zone 60–120 bpm · done&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This detail really represents a whole category of experience in this kind of architecture: &lt;strong&gt;you no longer wrestle with a framework&apos;s abstractions — you wrestle with HTTP&apos;s real constraints.&lt;/strong&gt; Those constraints are real, stable, and testable; and once you understand them, that class of bug never returns.&lt;/p&gt;
&lt;h3&gt;A Single Source of Truth: The Error Enum&lt;/h3&gt;
&lt;p&gt;Once error handling converges on the single place &lt;code&gt;form_err&lt;/code&gt;, the next need arises naturally: give the concept of &quot;error&quot; itself a single source of truth.&lt;/p&gt;
&lt;p&gt;In SPAs, errors are usually loose parts — frontend error constants here, backend exception classes there, thinly joined by the HTTP-status-code layer in between. In a server-rendered architecture, one enum (or exception hierarchy) can decide three things at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Error code&lt;/strong&gt; (&lt;code&gt;bad_request&lt;/code&gt;, &lt;code&gt;unauthorized&lt;/code&gt;, &lt;code&gt;not_found&lt;/code&gt;, &lt;code&gt;conflict&lt;/code&gt;, …)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTP status code&lt;/strong&gt; (400, 401, 404, 409, …)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Human-readable message for the user&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The simplified enum below is a portrait of that thinking:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enum AppError:
    case BadRequest(message)
    case Unauthorized(message)
    case Forbidden(message)
    case NotFound(message)
    case RateLimited(message)
    case Internal(message)

    def code(self):
        # &quot;bad_request&quot;, &quot;unauthorized&quot;, ...
    def status(self):
        # 400, 401, 403, 404, 429, 500
    def message(self):
        # Human-readable message
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the whole chain connects: any handler anywhere &lt;code&gt;raise NotFound(&quot;no such session&quot;)&lt;/code&gt;, and it automatically becomes — a red toast for htmx, a &lt;code&gt;404&lt;/code&gt; plus &lt;code&gt;{&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: &quot;not_found&quot;, &quot;message&quot;: &quot;no such session&quot;}&lt;/code&gt; for APIs, a re-rendered 404 page for no-JS browsers. &lt;strong&gt;Three exits, one definition.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Internal vs. user errors must be treated differently. &lt;code&gt;Internal&lt;/code&gt; errors may hide database details, stack traces, internal paths — things that must &lt;strong&gt;never&lt;/strong&gt; appear in response bodies. The right move is intercepting them at the response boundary: log them (let &lt;code&gt;Internal&lt;/code&gt; details into the logs), and hand the client only a generic &lt;code&gt;&quot;internal error&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def into_response(self):
    status = self.status()
    if self is Internal(detail):
        log.error(detail)              # details go to logs only
        message = &quot;internal error&quot;     # the client only ever sees this
    else:
        message = self.message()
    return Response(status, json({&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: self.code(), &quot;message&quot;: message}))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Putting that &lt;code&gt;log.error(detail)&lt;/code&gt; at the response boundary has a bonus benefit: even if some handler forgot to log on error, as long as it returned &lt;code&gt;Internal&lt;/code&gt;, the logs are guaranteed a record. You&apos;ve turned &quot;internal errors must be logged&quot; from a discipline every developer must remember into something the architecture guarantees automatically.&lt;/p&gt;
&lt;h3&gt;Why This Setup Holds Together Here&lt;/h3&gt;
&lt;p&gt;Looking back you might ask: couldn&apos;t SPAs do all this too? Pop a toast in &lt;code&gt;fetch&lt;/code&gt;&apos;s &lt;code&gt;.catch&lt;/code&gt; and be done?&lt;/p&gt;
&lt;p&gt;Of course they could. The difference isn&apos;t whether you can, but &lt;strong&gt;how many times you do it&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In an SPA, error handling belongs to each call site. You have a global interceptor, but every component still decides its own failure look — loading state, error state, retry button, boundary components. Errors are the easiest part of a frontend app to quietly drop.&lt;/p&gt;
&lt;p&gt;In the htmx architecture, error presentation is pushed back to the server. The server &lt;strong&gt;already knows&lt;/strong&gt; what went wrong, how badly, and what the user should see. And htmx&apos;s response contract lets you encode that knowledge &lt;strong&gt;once&lt;/strong&gt; — in &lt;code&gt;form_err&lt;/code&gt; and &lt;code&gt;AppError&lt;/code&gt; — after which every endpoint, whether editing a card, bulk-assigning, or deleting a record, inherits the same behavior automatically.&lt;/p&gt;
&lt;p&gt;This touches the deepest philosophy of the architecture: &lt;strong&gt;keep decisions about &quot;state&quot; and &quot;errors&quot; on the server, and let HTML only express the outcome.&lt;/strong&gt; Error handling matters because it&apos;s where that philosophy first becomes concrete and tangible.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Live Updates: SSE Instead of WebSocket&lt;/h2&gt;
&lt;p&gt;The hardest part of the dashboard is liveness. Cards must refresh every few seconds to reflect data uploaded by on-site devices. The SPA standard answer is WebSocket plus a client rendering layer. Here, the answer is suspiciously simple.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;body hx-ext=&quot;sse&quot;&amp;gt;
    &amp;lt;div id=&quot;grid&quot; sse-connect=&quot;/stream&quot; sse-swap=&quot;snapshot&quot;&amp;gt;
        {{ cards|safe }}
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;htmx&apos;s SSE extension maintains a server-push connection to &lt;code&gt;/stream&lt;/code&gt;. &lt;code&gt;sse-swap=&quot;snapshot&quot;&lt;/code&gt; declares: the server will push an event named &lt;code&gt;snapshot&lt;/code&gt; whose data payload is the HTML to swap into &lt;code&gt;#grid&lt;/code&gt;. So what the server pushes is &lt;strong&gt;not JSON data, but already-rendered card HTML&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;event: snapshot
data: &amp;lt;div id=&quot;grid&quot;&amp;gt;...complete card HTML...&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client swaps it in on arrival. When live data is just &quot;a projection of server state,&quot; rendering should happen on the side that owns the state — the server — not redone in a browser that must first rebuild the state. So you &lt;strong&gt;drop an entire client rendering layer&lt;/strong&gt;: no more &quot;receive JSON → update store → trigger re-render&quot; chain, only &quot;receive HTML → replace DOM.&quot;&lt;/p&gt;
&lt;p&gt;The price sits on the server side: you must manage that long-lived connection&apos;s lifecycle — who&apos;s listening, whether to clean up on disconnect, how to broadcast to all listeners when data changes. That sounds scary, but it&apos;s essentially a &quot;subscriber list + broadcast loop,&quot; an old trick every backend framework knows, in a few dozen lines (in production you also handle slow consumers, reconnect stampedes, and such edges — but that&apos;s another topic). And it&apos;s &lt;strong&gt;one-way only&lt;/strong&gt; — data flows from server to browser; any client-initiated command (editing a card, say) still goes over ordinary HTTP POST. The live channel carries no &quot;instructions back.&quot;&lt;/p&gt;
&lt;h3&gt;Why SSE Instead of WebSocket&lt;/h3&gt;
&lt;p&gt;Because live updates in this app are &lt;strong&gt;one-way&lt;/strong&gt; — data flows from server to browser, and the browser barely sends anything back. SSE is designed for exactly that shape: a long-lived connection over HTTP, with &lt;code&gt;EventSource&lt;/code&gt; reconnecting automatically, and the connection naturally carrying same-origin cookies, so it seamlessly reuses your existing HTTP auth — an already-logged-in user&apos;s session cookie rides along with the SSE connection, no extra auth step needed.&lt;/p&gt;
&lt;p&gt;WebSocket&apos;s cost is stuffing in &quot;two-way&quot; — a capability you don&apos;t even use — as a price you must pay: no auto-reconnect on drop, so you implement retries and heartbeats yourself; while it also upgrades from an HTTP handshake (so it can carry cookies too), it&apos;s more like an independent bidirectional pipe, living a separate life from the HTTP &quot;request-response-push&quot; lifecycle. When all you need is a one-way push stream, WebSocket is overkill.&lt;/p&gt;
&lt;h3&gt;Being Honest: The DOM Is State Too&lt;/h3&gt;
&lt;p&gt;In section 2 I said &quot;there is only one state.&quot; That was idealized.&lt;/p&gt;
&lt;p&gt;Because the DOM itself is a second state. The user is filling in a form inside a card, has a dropdown expanded, focus sitting in some input — none of which the server knows, yet all of which is real state the user is holding. When the server pushes a &lt;code&gt;snapshot&lt;/code&gt; that replaces the whole &lt;code&gt;#grid&lt;/code&gt;, the input, the open menu, the focus — all gone. Far worse than a &quot;stale card.&quot;&lt;/p&gt;
&lt;p&gt;So the &quot;implicit dual-state&quot; problems — concurrent writes, multiple tabs, the back button — don&apos;t disappear in server-rendered architectures; they just change shape. The honest version: state synchronization goes from &quot;the framework manages it&quot; to &quot;you manage it with HTTP primitives.&quot; SSE events, &lt;code&gt;hx-swap&lt;/code&gt;, OOB patches are your primitives; and &quot;when pushes should yield to what the user is doing&quot; is your responsibility — no framework judges that for you.&lt;/p&gt;
&lt;p&gt;Here&apos;s how we manage it: &lt;strong&gt;when swapping, skip cards the user is editing.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# A card counts as &quot;being edited&quot; if either holds:
def card_is_editing(card):
    focus = document.active_element
    if focus is inside card and focus is an input (input / textarea / select):
        return true
    if card contains an open edit form:
        return true
    return false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So when each &lt;code&gt;card&lt;/code&gt; event arrives, first check whether the target card is being edited — if so, &lt;code&gt;preventDefault()&lt;/code&gt;, skip this swap, and let that second&apos;s update lapse. And the whole-grid &lt;code&gt;snapshot&lt;/code&gt; is more cautious: if &lt;strong&gt;any&lt;/strong&gt; card is being edited, stash this snapshot and apply it once editing ends — at the price of the whole grid staying stale during the edit. Broadcasts tick once a second, so an edited card never stays stale forever: the moment editing ends, the next tick brings a fresh one.&lt;/p&gt;
&lt;p&gt;You do have to write a small piece of JavaScript arbitrating between &quot;live pushes&quot; and &quot;what the user is doing.&quot; It&apos;s inelegant, but honest — it admits the DOM is state, then explicitly decides which state yields to which, and when. That&apos;s what this architecture really looks like: no magic, just HTTP primitives laid out one by one.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Islands: When a Chunk of Page Truly Needs to Be &quot;Alive&quot;&lt;/h2&gt;
&lt;p&gt;By now a reader will ask: all well and good, but what about a genuinely interactive component — a chart with tab switching and live refresh, where clicking a tab swaps datasets and hovering shows tooltips? htmx&apos;s partial replacement handles forms and lists, but not &quot;a widget in the browser managing its own state.&quot;&lt;/p&gt;
&lt;p&gt;The answer is admitting it, then carving out a small self-contained &quot;island.&quot; That&apos;s not a betrayal of the argument so far — it&apos;s drawing the boundary clearly.&lt;/p&gt;
&lt;h3&gt;Most Pages Are Server-Rendered; a Few Are Not&lt;/h3&gt;
&lt;p&gt;In my dashboard, the vast majority of pages follow one rule: the server renders HTML, htmx handles partial updates. With one exception — a participant history detail page with a chart. It has &quot;this session / across sessions&quot; tabs, a live-ticking frame count, a summary row, and a Chart.js line chart.&lt;/p&gt;
&lt;p&gt;That widget needs something server rendering struggles to provide: &lt;strong&gt;client state&lt;/strong&gt;. The user clicks a tab, the chart switches data sources, but the server doesn&apos;t need to know &quot;which tab is showing&quot; — that&apos;s purely presentational state, irrelevant to the server. Forcing it through htmx means a server round-trip per tab switch, interrupting the experience for a piece of local UI state. Not worth it.&lt;/p&gt;
&lt;p&gt;So the chart is an island: a self-contained chunk on the page with its own little runtime, surrounded on all sides by server-rendered everything.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    Page[&quot;Detail page (server-rendered)&quot;]
    Page --&amp;gt; Header[&quot;Header and static fields&quot;]
    Page --&amp;gt; Panel[&quot;#detail-panel form area&amp;lt;br/&amp;gt;(htmx partial swaps)&quot;]
    Page --&amp;gt; Island[&quot;History-chart island&amp;lt;br/&amp;gt;(petite-vue + Chart.js)&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div
    v-scope=&quot;HistoryChart({ pid: 42, cutoff: 0.6 })&quot;
    @vue:mounted=&quot;start&quot;
    @vue:unmounted=&quot;stop&quot;
&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;session&apos;)&quot; :disabled=&quot;tab === &apos;session&apos;&quot;&amp;gt;This session&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;all&apos;)&quot; :disabled=&quot;tab === &apos;all&apos;&quot;&amp;gt;Across sessions&amp;lt;/button&amp;gt;
    &amp;lt;span v-if=&quot;headerCode&quot;&amp;gt;{{ headerCode }} · {{ frameCount }} frames&amp;lt;/span&amp;gt;
    &amp;lt;canvas&amp;gt;&amp;lt;/canvas&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I used petite-vue here — a 6KB, no-build-step mini-Vue built for &quot;adding reactivity to a small chunk of HTML.&quot; It&apos;s not a second full framework, just a small tool letting the island respond to user input internally.&lt;/p&gt;
&lt;h3&gt;Islands Still Respect the Core Principle&lt;/h3&gt;
&lt;p&gt;Islands may hold client state, but &lt;strong&gt;data still comes from the server&lt;/strong&gt;. The chart bootstraps with a same-origin fetch for history, carrying the existing session cookie — zero extra auth complexity. And its liveness rides the same EventSource (SSE) from the previous section — one stream carrying multiple named events: the grid gets &lt;code&gt;snapshot&lt;/code&gt; and swaps HTML, the island gets &lt;code&gt;card&lt;/code&gt; and refetches its own data. There is still exactly one source of data; the island is just a lively view of it.&lt;/p&gt;
&lt;p&gt;That&apos;s a dividing line: islands hold &lt;strong&gt;ephemeral, presentational state&lt;/strong&gt; — which tab is selected, which frame the chart is drawn up to. The server&apos;s state — the records themselves, quality thresholds, which frames are valid — remains the single source of truth, still owned by the server. You&apos;ve introduced a small runtime, but no second truth to synchronize.&lt;/p&gt;
&lt;h3&gt;Islands Must Live Outside the Swap&lt;/h3&gt;
&lt;p&gt;Islands have one easy-to-violate discipline: &lt;strong&gt;they can&apos;t sit inside htmx&apos;s swap targets.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The reason is mount timing. petite-vue&apos;s &lt;code&gt;createApp().mount()&lt;/code&gt; runs once at page load, compiling the &lt;code&gt;[v-scope]&lt;/code&gt; elements present in the DOM at that moment. htmx swaps new HTML into the page afterward — if that swapped-in HTML contains a &lt;code&gt;v-scope&lt;/code&gt;, petite-vue knows nothing about it; it&apos;s just dead tags with no reactivity. To revive it you&apos;d manually remount after every swap, while handling the previous mount&apos;s teardown, event listeners, and EventSource cleanup yourself. Once you walk that road, you&apos;ve stitched two runtimes together, and complexity snaps back with a vengeance.&lt;/p&gt;
&lt;p&gt;So in practice, swap targets and islands are &lt;strong&gt;sibling neighbors in the DOM, never nested parent and child&lt;/strong&gt;. Back on that detail page: above sits a &lt;code&gt;#detail-panel&lt;/code&gt; swap target with pure forms inside — setting ranges, assigning actions, all via htmx partial swaps; below sits a chart island that doesn&apos;t swap via htmx but refreshes itself via its own same-origin fetch plus EventSource. Two update channels, each minding its own, never nested.&lt;/p&gt;
&lt;p&gt;That discipline is two faces of the same thing as the previous section: the island&apos;s data doesn&apos;t come from htmx response bodies, but from its own line. Precisely because of that, it can rest safely outside any swap, never uprooted by a partial replacement.&lt;/p&gt;
&lt;h3&gt;A Familiar Price&lt;/h3&gt;
&lt;p&gt;Islands aren&apos;t free. You&apos;ve reintroduced client state, a hand-written JS file, and a second runtime (petite-vue). &quot;The cost of SPA&quot; is back — just strictly confined to a small box of a few dozen lines, instead of suffusing the whole app.&lt;/p&gt;
&lt;p&gt;And there are only-step-on-it-to-know pitfalls. petite-vue deep-wraps every property in its scope in a Proxy for reactivity — usually great; but if you put a Chart.js instance or an EventSource inside the reactive scope, their internal slots break under the Proxy and method calls throw outright. The fix is keeping those instances in closure-local variables, invisible to the reactivity system. Same category of experience as that Latin-1 header earlier: what you wrestle changes from &quot;a framework&apos;s abstractions&quot; to &quot;some specific mechanism&apos;s boundary&quot; — and once understood, never wrong again.&lt;/p&gt;
&lt;h3&gt;Island Architecture, Not &quot;All or Nothing&quot;&lt;/h3&gt;
&lt;p&gt;If you know Astro, you&apos;ll recognize the &quot;islands architecture&quot; pattern in this. The only difference: Astro made it a first-class framework citizen, while here it&apos;s hand-rolled and smaller. The judgment behind both is the same — &lt;strong&gt;deciding what needs a client runtime is done component by component, not for the whole app at once.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The most important judgment is really this: htmx&apos;s value was never &quot;refusing JavaScript,&quot; but &quot;deferring JavaScript until it&apos;s truly needed.&quot; The vast majority of interactions — a form, a list refresh, an error hint — need only HTML and HTTP; only when you meet a widget that genuinely manages its own state do you carve out an island. Server-render by default, introduce runtimes on demand — not the other way around.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;8. When This Pattern Is the Right One&lt;/h2&gt;
&lt;p&gt;I don&apos;t want to write this as an htmx hit piece or puff piece. It&apos;s a tool; tools belong in the right settings. This checklist is my judgment:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This pattern fits you when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The app is essentially &quot;multiple views over server state&quot; — dashboards, admin backends, content display.&lt;/li&gt;
&lt;li&gt;It&apos;s form-dense and CRUD-driven.&lt;/li&gt;
&lt;li&gt;Liveness is mostly pushes, without complex client-side interaction.&lt;/li&gt;
&lt;li&gt;The team is stronger on backend than frontend, or you just don&apos;t want to maintain two codebases and two build chains.&lt;/li&gt;
&lt;li&gt;You want &quot;logout really logs out&quot; and &quot;there really is only one state.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;This pattern doesn&apos;t fit you when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You need offline-first.&lt;/li&gt;
&lt;li&gt;The client has a complex local state machine (drag-and-drop, rich-text editing, collaborative editing).&lt;/li&gt;
&lt;li&gt;The page itself is a local application rather than a projection of server state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In those settings the browser really is a runtime, and SPA is the right answer.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Coda: The Mental Model Was Right All Along&lt;/h2&gt;
&lt;p&gt;The ASPX and PHP generation wasn&apos;t dumb — the right tools just hadn&apos;t grown yet. Their instinct — &lt;strong&gt;the server owns state, HTML owns expression&lt;/strong&gt; — was never wrong; what was wrong were the tools in their hands: no type-safe templates, no declarative partial updates, no genuinely usable CSS system, no clean live-push channel.&lt;/p&gt;
&lt;p&gt;htmx filled in partial updates, template engines filled in type safety, Tailwind filled in styling, SSE filled in liveness. When the tools arrived, that old mental model suddenly became modern again — not because the world regressed to 2005, but because the idea that &quot;the server understands the page&quot; had been waiting there to be redeemed all along.&lt;/p&gt;
&lt;p&gt;If you&apos;ve ever felt exhausted deep inside &lt;code&gt;node_modules&lt;/code&gt;, between two routing tables, in the mire of cache invalidation — maybe look back. You&apos;ll find you&apos;re not stepping backward, but recovering a road that was right all along.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Further Reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Hypermedia Systems&lt;/em&gt; — by Carson Gross, Adam Stepinski, and Denis Pashevsky, free to read online: hypermedia.systems. The systematic treatment of every idea above, far more complete than this post.&lt;/li&gt;
&lt;li&gt;The essays section of the htmx site (htmx.org/essays) — &quot;hypermedia as the engine of application state&quot; (HATEOAS), when to use hypermedia and when to use SPA, discussed more rigorously there.&lt;/li&gt;
&lt;li&gt;The htmx official docs (htmx.org/docs) — the authoritative reference for every attribute mentioned here: &lt;code&gt;hx-swap&lt;/code&gt;, &lt;code&gt;HX-Trigger&lt;/code&gt;, the SSE extension, and the rest.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>回到未来：我为什么用 htmx、模板引擎和 Tailwind 构建现代 Web 应用</title><link>https://blogs.ansonate.ink/posts/htmx-tailwind/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/htmx-tailwind/</guid><description>用 htmx、服务端模板和 Tailwind 构建现代 Web 应用：表单、错误处理、SSE 与岛屿架构的实践总结。</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;一个关于“复古工具”为何在今天依然成立的故事。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 一种似曾相识的感觉&lt;/h2&gt;
&lt;p&gt;最近我接了一个实时数据仪表盘。它需要用户登录、权限分级（管理员、志愿者、客户端各看各的）、一个几秒刷新一次的实时卡片网格、还有一堆密集的表单操作。听起来像是一个典型的 2026 年项目。&lt;/p&gt;
&lt;p&gt;我的第一反应也是典型的 2026 年反应：React 加 React Router，React Query 管服务端状态，Zustand 管客户端状态，某个表单库，某个 CSS-in-JS 方案，再配一套 WebSocket 客户端。写到 &lt;code&gt;npm create&lt;/code&gt; 的那一步，我停住了。&lt;/p&gt;
&lt;p&gt;因为我的脑海里突然闪过 20 年前的解法。那时候页面是服务器给的，表单是真的 &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;，出错是真的 HTTP 状态码。用户提交，页面导航，就这么简单。当然，那个时代有它自己的地狱——ViewState、回传、服务器控件层层嵌套的不可预测性——所以我并不是在怀念它。我怀念的是那个&lt;strong&gt;心智模型&lt;/strong&gt;：服务器懂页面，状态只有一个，HTML 是状态的投影。&lt;/p&gt;
&lt;p&gt;然后我意识到，这十几年里我们不是&quot;放弃了&quot;那个模型，而是被浏览器当时的能力不足&lt;strong&gt;逼&lt;/strong&gt;着离开了它。当时的浏览器只有粗笨的全页导航——XHR 虽然早就存在，但真正成熟、声明式的局部更新当时还不存在——所以我们才发明了客户端路由、客户端状态、客户端渲染，把一个完整的应用运行时塞进浏览器，只为了回避&quot;每次交互都重新加载整个页面&quot;的原始。&lt;/p&gt;
&lt;p&gt;而今天，htmx 把这个漏洞补上了。&lt;/p&gt;
&lt;p&gt;htmx 不是复古。htmx 补完了 HTML 当年没有兑现的承诺：让&lt;strong&gt;任何元素&lt;/strong&gt;都能发起请求，让响应的 HTML &lt;strong&gt;替换目标片段&lt;/strong&gt;，让服务器推送&lt;strong&gt;局部更新&lt;/strong&gt;，而不是整页刷新。你不需要为此引入一个笨重的框架，你只需要给普通的 HTML 加上几个属性。&lt;/p&gt;
&lt;p&gt;这篇文章想讲清楚的，是一套设计模式：&lt;strong&gt;为什么不做一个 SPA，为什么 htmx + 模板引擎 + Tailwind 这套组合能打，登录登出、错误处理、实时更新和交互式组件分别怎么落地&lt;/strong&gt;。它跟具体语言无关——我用的是 Rust 和 axum，但你换成 Django、Rails、Phoenix、ASP.NET，论证依然成立。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 为什么不做一个 SPA&lt;/h2&gt;
&lt;p&gt;我不想用&quot;SPA 是坏的&quot;来立论——它不是，它在很多场景下是正解。我想说的是：&lt;strong&gt;在这个应用里，SPA 的每一层抽象，我都要付两次钱。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;状态的双胞胎&lt;/h3&gt;
&lt;p&gt;这是最根本的一条。在一个 SPA 里，你永远在维护两份状态：服务器上的真实状态，和浏览器里的镜像状态。用户的积分是 100 分，这 100 分存在于数据库里，也存在于某个 React 组件树的 state 里。为了让这两份保持一致，你需要 cache invalidation、query keys、乐观更新、revalidation——一整套专门的库和心智负担，去解决一个&lt;strong&gt;你自己制造出来的&lt;/strong&gt;同步问题。&lt;/p&gt;
&lt;p&gt;而在服务端渲染的应用里，状态只有一个。页面就是状态的投影，仅此而已。服务器说&quot;这个卡片现在的状态是 X&quot;，浏览器就把 X 显示出来。没有第二份状态需要同步，因为从来没有第二份状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    subgraph SPA[&quot;SPA — 两份状态&quot;]
        direction TD
        A[&quot;服务器状态&quot;] &amp;lt;--&amp;gt;|&quot;同步：缓存失效 / query keys / 乐观更新&quot;| B[&quot;客户端镜像 state&quot;]
    end
    subgraph HTMX[&quot;htmx — 一份状态&quot;]
        direction TD
        C[&quot;服务器状态&quot;] --&amp;gt;|&quot;渲染 HTML&quot;| D[&quot;页面&quot;]
    end
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;路由的双胞胎&lt;/h3&gt;
&lt;p&gt;SPA 有两条路由系统：客户端路由（用户点链接，浏览器地址栏变化，React Router 决定渲染哪个组件），和服务端 API 路由（客户端去取数据的那套端点）。这两套路由要各自维护权限、各自的错误形态、各自的加载态。一个&quot;只有管理员能看&quot;的页面，你既要在前端守卫（不渲染），又要在后端守卫（不返回数据）——因为前端守卫永远可以被绕过。&lt;/p&gt;
&lt;p&gt;服务端渲染的应用只有一套路由。权限在一个地方检查，页面在一个地方组装，链接就是真的链接，导航就是真的 HTTP 请求。&lt;/p&gt;
&lt;h3&gt;构建成本&lt;/h3&gt;
&lt;p&gt;SPA 的前端是一条独立的生产线：打包链、Tree Shaking、代码分割、类型同步（前端 TypeScript 类型要和后端 DTO 对齐）、API 版本协商。这些工作的本质是：&lt;strong&gt;把 HTML 从服务器搬走，再把数据搬回来。&lt;/strong&gt; 如果你本来的目标就是&quot;服务器有状态，浏览器显示状态&quot;，那你搬来搬去图什么呢？&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SPA 把浏览器当成一个应用运行时；htmx 把浏览器当成一个 HTML 渲染器。前者你需要一个框架，后者你只需要一个 Web 框架。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;为了公平，我得说清楚什么时候 SPA 仍然是对的：离线优先的应用、复杂的客户端状态机（比如一个有几十种拖拽交互的编辑器）、富文本/协作编辑、以及那些&quot;页面本身就是一个本地应用&quot;的产品。这些场景里，客户端确实是一个运行时，而不是一个渲染器。但我这个仪表盘不是。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. htmx + 模板引擎 + Tailwind：三件套为什么能搭&lt;/h2&gt;
&lt;p&gt;单独看这三个工具，每个都不起眼。它们的威力来自组合。&lt;/p&gt;
&lt;h3&gt;模板引擎：服务端的组件&lt;/h3&gt;
&lt;p&gt;模板引擎（Askama、Jinja、ERB、Tera——随你喜欢哪个）给了你编译期的类型检查和自动 HTML 转义，更重要的是：它让你在&lt;strong&gt;服务端组装片段&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这里有个关键洞察：在 SPA 里你写一个 &lt;code&gt;&amp;lt;Component&amp;gt;&lt;/code&gt; 返回 JSX；在 htmx 里你写一个模板函数，返回一段 HTML。&lt;strong&gt;粒度完全一样，但平台换成了 HTTP。&lt;/strong&gt; 一个&quot;卡片组件&quot;就是一段模板，一个 handler 调它、填好数据、返回 HTML。&lt;/p&gt;
&lt;h3&gt;htmx：请求与替换的声明化&lt;/h3&gt;
&lt;p&gt;htmx 把&quot;发请求、换内容&quot;这件事声明成了几个 HTML 属性。看一个最典型的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form hx-post=&quot;/sessions/42/assignments&quot;
      hx-target=&quot;#card-17&quot;
      hx-swap=&quot;outerHTML&quot;&amp;gt;
    &amp;lt;input name=&quot;exercise&quot; placeholder=&quot;exercise&quot; required&amp;gt;
    &amp;lt;button&amp;gt;Assign&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户点下按钮，htmx 拦截提交，POST 到服务器。服务器返回一段&lt;strong&gt;新的卡片 HTML&lt;/strong&gt;——就是原来那个卡片，但内容更新了。htmx 把这段 HTML 替换到 &lt;code&gt;#card-17&lt;/code&gt; 的位置。没有 JSON，没有 client store，没有重渲染调度，没有 diff 算法。整个&quot;更新一个卡片&quot;的交互，就这三行属性加一个服务端模板函数。&lt;/p&gt;
&lt;h3&gt;Tailwind：模板即组件&lt;/h3&gt;
&lt;p&gt;因为样式写在模板里，&lt;strong&gt;模板就变成了组件&lt;/strong&gt;。每个片段自带它的样式，你不再在 CSS 文件、JS 文件、组件文件之间跳来跳去。改一个卡片的样式，你改的是渲染那张卡片的模板。&lt;/p&gt;
&lt;p&gt;而 utility class 的好处在这里会被无限放大——它让你几乎不用写 CSS。没有语义化类名、没有 BEM 那套命名、没有选择器层级，&lt;code&gt;flex&lt;/code&gt;、&lt;code&gt;gap-3&lt;/code&gt;、&lt;code&gt;text-sm&lt;/code&gt;、&lt;code&gt;font-mono&lt;/code&gt; 直接写在元素上。这个好处在别处只是&quot;少敲几个字&quot;；但到了模板的世界里，你的&quot;组件&quot;就是一段服务端片段，样式住在片段里、跟着片段一起被渲染和替换。一张卡片从服务器回来，样式已经齐了；改一张卡片的样式，改的就是那张卡片的模板。&lt;strong&gt;一个片段是自包含的&lt;/strong&gt;—。结构、行为、样式都在同一个地方，你不需要在大脑里维护一张&quot;这个 class 定义在哪、又在哪一层被覆盖&quot;的地图，心智负担小得多。&lt;/p&gt;
&lt;p&gt;更妙的是 Tailwind 的构建模型。它在构建时扫描你的模板文件，编译出一份静态 CSS。浏览器拿到的是&lt;strong&gt;一份&lt;/strong&gt;带指纹缓存的 &lt;code&gt;app.css&lt;/code&gt;。没有 runtime 的 CSS-in-JS，没有运行时注入 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; 的成本，没有水合。样式的问题在构建期就全部解决完了，运行时干干净净。&lt;/p&gt;
&lt;h3&gt;现代 SSR：为了刷墙，把房子拆了&lt;/h3&gt;
&lt;p&gt;有人可能会说：Next.js、Remix 不也做服务器渲染吗？&lt;/p&gt;
&lt;p&gt;它们当然做。但它们的路子是&lt;strong&gt;既想要框架，又想要 SSR，于是把两者缝在一起&lt;/strong&gt;：你照样写 React/Vue 组件、维护客户端状态、跑一整套打包链，然后框架再把这些东西&quot;水合&quot;（hydrate）到服务器渲染出来的 HTML 上。水合本身就是一笔开销；于是又冒出 Server Components、island 划分这些机制来&lt;strong&gt;减少&lt;/strong&gt;水合——但这套东西一层套一层，边界和规则越来越多，全是为了调和&quot;客户端框架&quot;和&quot;服务器渲染&quot;这两股天然的张力。&lt;/p&gt;
&lt;p&gt;这就是我说的&quot;为了刷一面墙，把房子拆了&quot;。你想让首屏快一点、SEO 好一点，结果引入了一整个 hydration 机制，去救那个&lt;strong&gt;本可以不存在&lt;/strong&gt;的客户端运行时。htmx 走的是另一条路：&lt;strong&gt;根本不要客户端框架&lt;/strong&gt;，于是&quot;水合&quot;这件事压根不存在——HTML 从服务器来，就是它最终的样子，没有第二次初始化，没有两份状态的交接。&lt;/p&gt;
&lt;p&gt;（Astro 是这一族里最清醒的：默认零 JS、按需挂载 island。但它仍然要求你拥抱组件框架和构建链；而 htmx 的答案更彻底——连那个组件框架都不要。）&lt;/p&gt;
&lt;h3&gt;复古的不是工具，是心智模型&lt;/h3&gt;
&lt;p&gt;这套东西写起来确实有 aspx 和 php 的既视感。因为当年 ASPX 和 PHP 那一代人不是笨，他们是对的：&lt;strong&gt;服务器负责状态，HTML 负责表达。&lt;/strong&gt; 他们只是被工具拖累了：没有类型安全的模板，没有声明式的局部更新，没有一个真正可用的 CSS 系统，所以只好手写 &lt;code&gt;Response.Write&lt;/code&gt; 和 &lt;code&gt;echo&lt;/code&gt;，然后在全页回传的泥潭里挣扎。&lt;/p&gt;
&lt;p&gt;今天我们用同样的心智模型，但配上了 2026 年该有的工具：类型安全的模板、属性驱动的局部更新、构建期编译的 CSS。&lt;strong&gt;复古的不是工具，是&quot;服务器懂页面&quot;这个想法——而它从来没错过。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这些想法不是我发明的——Carson Gross 的《Hypermedia Systems》和 htmx 官网的 essays 专栏，早就把&quot;超媒体作为应用状态的引擎&quot;（HATEOAS）这件事讲得比我透彻得多。我在这里做的，只是把这些原则落进一个真实项目，然后诚实记录哪些地方顺利、哪些地方得补 JavaScript。文末附了深入阅读的链接。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 登录与登出：当表单变回真的表单&lt;/h2&gt;
&lt;p&gt;登录是&quot;传统 Web&quot;和 SPA 分歧最大的地方，也是最能说明&quot;什么时候用 htmx、什么时候不用&quot;的地方。&lt;/p&gt;
&lt;h3&gt;一个反直觉的决定：登录表单故意不用 htmx&lt;/h3&gt;
&lt;p&gt;我的登录表单就是一个朴素的 &lt;code&gt;&amp;lt;form method=&quot;post&quot;&amp;gt;&lt;/code&gt;，没有 &lt;code&gt;hx-*&lt;/code&gt; 属性。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form method=&quot;post&quot; action=&quot;/login&quot; id=&quot;login-form&quot;&amp;gt;
    &amp;lt;label&amp;gt;Username
        &amp;lt;input name=&quot;username&quot; autocomplete=&quot;username&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;label&amp;gt;Password
        &amp;lt;input name=&quot;password&quot; type=&quot;password&quot; autocomplete=&quot;current-password&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;button type=&quot;submit&quot;&amp;gt;Log in&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为什么？因为登录成功之后的动作——写入会话、跳转到仪表盘——&lt;strong&gt;天然就是一次完整的页面导航&lt;/strong&gt;，而不是一次局部片段替换。登录不是&quot;把这段 HTML 换成那段 HTML&quot;的操作，它是&quot;把我从匿名状态切换成已登录状态&quot;的身份转换；转换完成之后，整页都该重新渲染，而不是 swap 一个小片段。&lt;/p&gt;
&lt;p&gt;更实际的理由来自浏览器的行为。一次真正的表单提交会让密码管理器正确地对 &lt;code&gt;autocomplete=&quot;current-password&quot;&lt;/code&gt; 自动填充，会让浏览器认得&quot;这是一个登录表单&quot;。而且——这一点对登录至关重要——&lt;strong&gt;一个普通的 &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; 在没有 JavaScript、或者 htmx 还没加载完成时，依然能工作&lt;/strong&gt;。登录是用户进入你系统的第一道门，你不希望这道门依赖一个刚加载的 JS 库才能打开。&lt;/p&gt;
&lt;p&gt;所以登录表单走&lt;strong&gt;真导航&lt;/strong&gt;。&lt;strong&gt;htmx 不是&quot;所有地方都要 htmx&quot;，而是&quot;在该用的地方用&quot;。&lt;/strong&gt; 登录这个场景，传统表单是更对的工具。&lt;/p&gt;
&lt;h3&gt;同一个端点，两种响应形态&lt;/h3&gt;
&lt;p&gt;但问题来了：同一个 &lt;code&gt;/login&lt;/code&gt; 端点，可能被浏览器导航调用，也可能被 htmx 调用（如果你在别处用 htmx 触发了它）。服务器怎么区分？&lt;/p&gt;
&lt;p&gt;答案是 &lt;code&gt;HX-Request&lt;/code&gt; 这个请求头。htmx 会在它发出的每个请求里带上 &lt;code&gt;HX-Request: true&lt;/code&gt;，服务器据此分支：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def login_redirect(headers, dest, cookie):
    if is_htmx(headers):
        # htmx 调用：200 + HX-Redirect，让浏览器做真导航
        return Response(200, {&quot;HX-Redirect&quot;: dest, &quot;Set-Cookie&quot;: cookie})
    else:
        # 普通浏览器导航：经典 303
        return Response(303, {&quot;Location&quot;: dest, &quot;Set-Cookie&quot;: cookie})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两边最终都导向同一次真导航，区别只是触发方式不同。这个分支模式——&lt;strong&gt;根据 &lt;code&gt;HX-Request&lt;/code&gt; 决定响应形态&lt;/strong&gt;——会贯穿整个错误处理章节，是这套架构的基石之一。&lt;/p&gt;
&lt;h3&gt;权限分级：用提取器，而不是中间件&lt;/h3&gt;
&lt;p&gt;登录成功之后，就是权限。这个应用有三种角色，看到完全不同的页面。我的做法是把&quot;当前是谁、什么角色&quot;做成一个请求提取器（extractor），路由声明自己需要哪种身份：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AdminPageAuth:
    # 从请求里解析出&quot;管理员身份&quot;，用于完整页面路由

    def from_request(state, request):
        match AdminAuth.from_request(state, request):
            case Ok(auth):
                return Ok(auth)
            case Err(Unauthorized(_)):
                # 未登录访问页面：303 到 /login，而不是一个 JSON 401
                return Err(Response(303, {&quot;Location&quot;: &quot;/login&quot;}))
            case Err(e):
                return Err(e.into_response())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;同一个&quot;未授权&quot;，有两种响应形态&lt;/strong&gt;：浏览器访问页面时，它需要被导航到登录表单；而 API 客户端调用时，它需要的是一个 &lt;code&gt;401&lt;/code&gt; 加 JSON。原因很简单——浏览器需要的是页面，客户端需要的是结构化数据。提取器在这里做了正确的事：完整页面路由的未授权变成 303，API 路由的未授权变成 401 JSON，两者都源自同一个 &lt;code&gt;Unauthorized&lt;/code&gt; 错误。&lt;/p&gt;
&lt;p&gt;角色不对又是另一回事。一个志愿者访问管理员页面，正确的响应不是&quot;请登录&quot;，而是 &lt;code&gt;403&lt;/code&gt;——你已经登录了，只是没权限。这个区分（401 未登录 vs 403 无权限）在很多系统里被糊在一起，在这里被明确地分开了。&lt;/p&gt;
&lt;h3&gt;登出：就这么多&lt;/h3&gt;
&lt;p&gt;登出是一个 POST，服务器端吊销 token、清除 cookie，然后 303 回登录页。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def logout(state, request):
    if session_cookie(request):
        revoke_token(session_cookie(request))
    return Response(303, {&quot;Location&quot;: &quot;/login&quot;, &quot;Set-Cookie&quot;: clear_session_cookie()})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就这么多。没有 &lt;code&gt;localStorage.removeItem(&apos;jwt&apos;)&lt;/code&gt;，没有 Redux reset，没有&quot;清除所有 client-side 缓存&quot;的仪式。因为状态本来就在服务器上，登出就是删掉服务器上的那条会话记录，然后清掉浏览器里的那个 cookie。&lt;/p&gt;
&lt;h3&gt;几个自然浮现的安全细节&lt;/h3&gt;
&lt;p&gt;这些细节是&quot;传统表单世界&quot;里非常自然的做法，却在 SPA + JWT 的世界里反而会变得别扭：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HttpOnly cookie&lt;/strong&gt;：会话 token 放在 HttpOnly cookie 里，JavaScript 根本读不到，XSS 也偷不走。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密码哈希在后台线程做&lt;/strong&gt;：argon2 这种哈希要跑约半秒，绝不能阻塞事件循环，丢进一个后台任务里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;速率限制按 (用户名, IP) 维度&lt;/strong&gt;：防暴力破解，限制器在哈希之前先拦一道。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;人机验证用显式渲染&lt;/strong&gt;：因为登录是纯表单（真导航），所以 CAPTCHA 组件要每次 fresh render、token 单次消费——这正好配合&quot;每次失败都重新渲染登录页&quot;的流程。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些东西没有任何一个是 htmx 的专属功能，但它们都&lt;strong&gt;因为回到了真表单的模型而变得顺理成章&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;CSRF：旧威胁，旧解法&lt;/h3&gt;
&lt;p&gt;用 session cookie 做鉴权，就必须重新面对一个老朋友：CSRF。攻击者诱导用户的浏览器，在不知情的情况下向你的站点发出一个携带 session cookie 的状态变更请求——因为你用的是 cookie，浏览器会自动把它带上。&lt;/p&gt;
&lt;p&gt;SPA + JWT 的世界里，这个问题基本被绕开了：token 放在 localStorage 里，由 JavaScript 手动塞进请求头，浏览器不会自动发送。但请注意，这只是把风险挪了地方——XSS 现在能直接偷走那个 token。而 cookie + HttpOnly 的路线里，token 是 JavaScript 读不到的，XSS 偷不走它——当然，XSS 仍能借受害者的已登录会话代为操作，只是拿不走凭证本身。代价是你得正面处理 CSRF。&lt;/p&gt;
&lt;p&gt;好消息是，CSRF 的解法又老又成熟，而且因为表单是服务器渲染的，落地起来格外自然：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SameSite 属性&lt;/strong&gt;：给 cookie 标上 &lt;code&gt;SameSite=Lax&lt;/code&gt;（现代浏览器的默认值）或 &lt;code&gt;Strict&lt;/code&gt;，浏览器就不会在跨站请求里带上它——这已经挡住了绝大多数 CSRF。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRF token&lt;/strong&gt;：一个随会话生成的密钥，服务器渲染表单时把它写进隐藏字段，提交时校验。因为是服务器渲染，塞进这个字段就是一行模板变量的事，不需要任何客户端逻辑。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这又是一个&quot;回到传统表单世界反而更省事&quot;的地方：威胁是旧的，解法也是旧的，而且两者都已经被验证了几十年。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 错误处理：htmx 最深的坑，也是最有价值的契约&lt;/h2&gt;
&lt;p&gt;如果你只想从这篇文章里带走一件事，那就是这一节。因为 htmx 的错误处理藏着一个非常反直觉的坑——它会让你的应用在某些时刻&lt;strong&gt;静默地失败&lt;/strong&gt;，而用户看到的只是一个什么都没发生的按钮。你一旦理解了这个问题，也就同时理解了这套架构里最优雅的部分：错误不再是一个散落在前端各处的杂务，而是一份可以放在一个函数里、被所有端点共享的契约。&lt;/p&gt;
&lt;h3&gt;那个坑：htmx 只 swap 成功响应&lt;/h3&gt;
&lt;p&gt;先想象一个最普通的场景。页面上有一张卡片，上面是一个表单，用户点下&quot;确认&quot;按钮，期待卡片被更新。在 SPA 里，你的 &lt;code&gt;fetch&lt;/code&gt; 会拿到一个 Promise，不管是成功还是失败，你都能在 &lt;code&gt;.catch&lt;/code&gt; 里做点什么。但在 htmx 里，流程是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;htmx 拦截表单提交，发一个 XHR 请求到服务器。&lt;/li&gt;
&lt;li&gt;服务器返回响应。&lt;/li&gt;
&lt;li&gt;htmx 拿响应的 body 去替换你指定的目标元素。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;关键在第三步。htmx 的默认行为是：&lt;strong&gt;只有 2xx 的响应才会被 swap 进页面&lt;/strong&gt;。如果你的端点返回了一个 400 或者 500，htmx 会触发一个 &lt;code&gt;htmx:responseError&lt;/code&gt; 事件，然后……什么都不做。原来的 DOM 原封不动，你的错误信息躺在响应的 body 里，只有在浏览器的 devtools 网络面板里才能看到。&lt;/p&gt;
&lt;p&gt;对于一个&quot;用户点了按钮、然后盯着屏幕等反馈&quot;的表单，这是灾难性的。没有红色提示，没有 toast，甚至没有控制台报错。用户会以为按钮坏了，再点一次，又点一次——每次服务器都在正确地拒绝这个请求，但每次拒绝都消失在空气里。&lt;/p&gt;
&lt;p&gt;问题的根源在于：&lt;strong&gt;htmx 把&quot;错误&quot;默认当成了一件不该呈现给用户的事&lt;/strong&gt;。这个默认值适合那种&quot;后台轮询失败了就安静地重试&quot;的场景，却完全不适合&quot;用户在表单里填错了一个字段&quot;的场景。&lt;/p&gt;
&lt;h3&gt;那个解法：双形态响应&lt;/h3&gt;
&lt;p&gt;所以我给自己定了一条规则，覆盖所有的 htmx 片段端点：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;对 htmx 调用方，永远返回 200，把结果——无论成功还是失败——放进响应体里；对其他人，返回真实的状态码。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;拆开来看，这条规则有两个分支。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;成功的时候&lt;/strong&gt;，返回 200，响应体就是要替换的那段新 HTML，再附带一个 &lt;code&gt;HX-Trigger&lt;/code&gt; 头，通知浏览器弹一个成功的 toast。用户看到卡片变了，右上角还闪过一句&quot;已保存&quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;失败的时候&lt;/strong&gt;，仍然返回 200，但响应体里带一段&quot;出错了&quot;的提示，同时用一个 &lt;code&gt;HX-Reswap: none&lt;/code&gt; 头告诉 htmx &lt;strong&gt;不要动目标元素&lt;/strong&gt;。这样一来，卡片不会被清空，用户填的内容还在，而错误信息通过一个 out-of-band（OOB）的补丁单独写到页面的 toast 容器里。&lt;/p&gt;
&lt;p&gt;而&lt;strong&gt;非 htmx 的调用方&lt;/strong&gt;——没有 JavaScript 的浏览器、直接调 API 的客户端、跑测试的代码——它们根本不该知道 toast 这种东西存在。它们要的就是一个正经的 HTTP 状态码和一个结构化的 JSON 错误体。&lt;/p&gt;
&lt;p&gt;整个机制的核心，是把&quot;错误怎么呈现&quot;这个决策收敛到一个函数里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def form_err(headers, error):
    if is_htmx(headers):
        # htmx 片段请求：永远 200，保留原 DOM，错误走 toast
        return Response(
            status=200,
            headers={
                &quot;HX-Trigger&quot;: toast_trigger(error.message, &quot;error&quot;),
                &quot;HX-Reswap&quot;: &quot;none&quot;,
            },
            body=toast_oob(error.message, &quot;error&quot;),   # 一段 OOB 的 #toast HTML
        )
    else:
        # 普通调用方：真实状态码 + JSON 错误体
        return error.into_response()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;失败分支返回的 200 是&lt;strong&gt;故意&lt;/strong&gt;的，而不是偷懒。正是因为 htmx 只 swap 2xx，我们才需要把&quot;失败&quot;伪装成一个&quot;成功拿到的响应&quot;，让 htmx 愿意把它交给页面去处理。真正的失败信息通过 &lt;code&gt;HX-Trigger&lt;/code&gt; 这个头，而不是状态码来传递。&lt;/p&gt;
&lt;h3&gt;一个可能的反驳：200 违反 HTTP 语义吗？&lt;/h3&gt;
&lt;p&gt;看到&quot;永远返回 200&quot;这条规则，一定会有人皱眉：这不是在说谎吗？错误就是错误，凭什么用 200 包装？&lt;/p&gt;
&lt;p&gt;这其实是把两件不同的事混在了一起。关键要分清：&lt;strong&gt;这个请求要的&quot;资源&quot;是什么。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对一个 htmx 片段请求来说，客户端要的不是&quot;这次操作成功与否&quot;这个事实，而是一段&lt;strong&gt;能放进那个 div 里的 HTML&lt;/strong&gt;。服务器确实成功地生产出了这段 HTML——只不过这段 HTML 渲染出来的是一句错误提示。从这个角度看，200 是完全诚实的：请求成功了，响应体就是&quot;渲染好的错误 DOM&quot;。真正的业务错误（&quot;字段填错了&quot;这类）则通过 &lt;code&gt;HX-Trigger&lt;/code&gt;（toast 消息）在带内传递。&lt;/p&gt;
&lt;p&gt;而真正需要状态码语义的地方——API 客户端、没有 JS 的浏览器、测试、监控——我们&lt;strong&gt;没有&lt;/strong&gt;破坏它们：这些调用方走的是另一条分支，拿到的是真实的 400/401/422 加 JSON。所以 HTTP 语义在它该生效的地方一分不少，只在&quot;客户端明确要 HTML 片段&quot;这一种情况里做了让步。&lt;/p&gt;
&lt;p&gt;我得把话说准确：这&lt;strong&gt;不是&lt;/strong&gt;在声称 HTTP 语义作废——404、409、422 这些状态码本来就是为业务结果准备的，它们也仍在错误枚举里照常用着。我们只是对&quot;客户端要一段 HTML 片段&quot;这一种情况做了一次务实的让步：htmx 只 swap 2xx（除非你调整 &lt;code&gt;htmx.config.responseHandling&lt;/code&gt;），所以把&quot;业务失败了&quot;放进响应体、而不是状态码。另一条同样合理的路是监听 &lt;code&gt;htmx:responseError&lt;/code&gt;、把真实的 4xx 响应体也 swap 进去；我们只是挑了更直白的这一条。&lt;/p&gt;
&lt;h3&gt;一个意想不到的细节：Latin-1 头&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;HX-Trigger&lt;/code&gt; 这个头看着简单，实际用起来有一个坑会咬你一口——而且它只在非英文内容上才会暴露。&lt;/p&gt;
&lt;p&gt;浏览器底层用 XHR 来发请求，而 XHR 拿到的响应头会被按 &lt;strong&gt;Latin-1（ISO-8859-1）&lt;/strong&gt; 解码。这意味着如果你在 &lt;code&gt;HX-Trigger&lt;/code&gt; 里塞了一段 UTF-8 文本——比如一句中文提示，或者一个 en-dash 字符——它到达浏览器的时候会变成乱码。一个 &lt;code&gt;Zone 60–120 bpm&lt;/code&gt; 会变成 &lt;code&gt;Zone 60â120 bpm&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;我一开始完全没意识到这件事，直到某条包含非 ASCII 字符的 toast 在浏览器里显示成乱码。调试了好一会儿才定位到：不是我的服务器编码错了，是响应头这条通道本身就只能安全地承载 ASCII。&lt;/p&gt;
&lt;p&gt;解法很直接：把 &lt;code&gt;HX-Trigger&lt;/code&gt; 里的 JSON 序列化之后，把所有非 ASCII 字符转义成 &lt;code&gt;\uXXXX&lt;/code&gt; 形式。结构化的 JSON 字符（引号、冒号、花括号）本来就是 ASCII，所以这个转换是安全的；而客户端用 &lt;code&gt;JSON.parse&lt;/code&gt; 解析的时候，会自动把 &lt;code&gt;\uXXXX&lt;/code&gt; 还原成真正的字符。乱码消失了，服务器发出去的东西和客户端解析出来的东西分毫不差。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 服务器端：任何非 ASCII 字符都转成 \uXXXX
def toast_trigger(message, kind):
    payload = json.dumps({&quot;toast&quot;: {&quot;msg&quot;: message, &quot;kind&quot;: kind}})
    return ascii_safe(payload)   # &quot;Zone 60\u2013120 bpm \u00b7 done&quot;

# 客户端：JSON.parse 自动还原
# &quot;Zone 60–120 bpm · done&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个细节代表的，其实是这类架构里一整个类别的经验：&lt;strong&gt;你不再和框架的抽象层搏斗，而是和 HTTP 本身的真实约束搏斗&lt;/strong&gt;。这些约束是真实的、稳定的、可测试的，而一旦你理解了它们，你的代码就再也不会有这一类 bug。&lt;/p&gt;
&lt;h3&gt;一份单一的来源：错误枚举&lt;/h3&gt;
&lt;p&gt;当错误处理被收敛到 &lt;code&gt;form_err&lt;/code&gt; 一个地方之后，你自然会产生下一个需求：让&quot;错误&quot;这个概念本身也有一个单一的来源。&lt;/p&gt;
&lt;p&gt;在 SPA 里，错误往往是散装的——前端有前端的错误常量，后端有后端的异常类，中间靠 HTTP 状态码这一层薄薄地对接。而在服务端渲染的架构里，你可以让一个枚举（或者一个异常层级）同时决定三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;错误码&lt;/strong&gt;（&lt;code&gt;bad_request&lt;/code&gt;、&lt;code&gt;unauthorized&lt;/code&gt;、&lt;code&gt;not_found&lt;/code&gt;、&lt;code&gt;conflict&lt;/code&gt;……）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTP 状态码&lt;/strong&gt;（400、401、404、409……）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给用户看的人类可读信息&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面这个简化的枚举就是这种思路的写照：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enum AppError:
    case BadRequest(message)
    case Unauthorized(message)
    case Forbidden(message)
    case NotFound(message)
    case RateLimited(message)
    case Internal(message)

    def code(self):
        # &quot;bad_request&quot;, &quot;unauthorized&quot;, ...
    def status(self):
        # 400, 401, 403, 404, 429, 500
    def message(self):
        # 人类可读的信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是整条链路就通了：一个 handler 里任何地方 &lt;code&gt;raise NotFound(&quot;no such session&quot;)&lt;/code&gt;，它就会自动变成——对 htmx 一个红色 toast、对 API 一个 &lt;code&gt;404&lt;/code&gt; 加 &lt;code&gt;{&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: &quot;not_found&quot;, &quot;message&quot;: &quot;no such session&quot;}&lt;/code&gt;、对无 JS 的浏览器一个重渲染的 404 页面。&lt;strong&gt;三种出口，一个定义。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;内部错误和用户错误必须被区别对待。&lt;code&gt;Internal&lt;/code&gt; 这类错误里可能藏着数据库的细节、栈信息、内部路径——这些东西&lt;strong&gt;永远不该&lt;/strong&gt;出现在响应体里。正确的做法是在响应边界处把它们拦住：记录日志（让 &lt;code&gt;Internal&lt;/code&gt; 的细节进日志），然后只给客户端一句笼统的 &lt;code&gt;&quot;internal error&quot;&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def into_response(self):
    status = self.status()
    if self is Internal(detail):
        log.error(detail)              # 细节只进日志
        message = &quot;internal error&quot;     # 客户端只看得到这句
    else:
        message = self.message()
    return Response(status, json({&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: self.code(), &quot;message&quot;: message}))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一行 &lt;code&gt;log.error(detail)&lt;/code&gt; 放在响应边界，还有一个附带的好处：即使某个 handler 忘了在出错时打日志，只要它返回了 &lt;code&gt;Internal&lt;/code&gt;，日志里就一定会有一条记录。你把&quot;内部错误一定要被记录&quot;这件纪律性的事，从&quot;每个开发者都要记得&quot;变成了&quot;架构自动保证&quot;。&lt;/p&gt;
&lt;h3&gt;为什么这套东西在这里成立&lt;/h3&gt;
&lt;p&gt;回头看你可能会问：这些东西 SPA 里不是也能做吗？&lt;code&gt;fetch&lt;/code&gt; 的 &lt;code&gt;.catch&lt;/code&gt; 里弹个 toast 不就行了？&lt;/p&gt;
&lt;p&gt;当然能。区别不在于能不能做，而在于&lt;strong&gt;做多少次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 SPA 里，错误处理是每个调用点各自的事。你有一个全局的拦截器，但每个组件仍然要决定自己失败时的样子——loading 态、error 态、重试按钮、边界组件。错误是前端应用里最容易悄悄漏掉的一块。&lt;/p&gt;
&lt;p&gt;而在 htmx 架构里，错误的呈现被推回了服务器。服务器&lt;strong&gt;本来就知道&lt;/strong&gt;发生了什么错、错到什么程度、该给用户看什么。而 htmx 的响应契约让你能把这个知识&lt;strong&gt;一次性地&lt;/strong&gt;编码进 &lt;code&gt;form_err&lt;/code&gt; 和 &lt;code&gt;AppError&lt;/code&gt; 两个地方，然后每一个端点——不管是编辑卡片、批量分配、还是删除记录——都自动继承同一套行为。&lt;/p&gt;
&lt;p&gt;这其实触及了这套架构最根本的哲学：&lt;strong&gt;把关于&quot;状态&quot;和&quot;错误&quot;的决策留在服务器端，让 HTML 只负责表达结果。&lt;/strong&gt; 错误处理之所以重要，是因为它是这个哲学第一次变得具体、变得可触摸的地方。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 实时更新：SSE 而不是 WebSocket&lt;/h2&gt;
&lt;p&gt;仪表盘里最难的一环是实时性。卡片要几秒刷新一次，反映现场设备上传的数据。SPA 的标准答案是 WebSocket + 一个客户端渲染层。而在这里，答案简单得让人怀疑自己是不是漏了什么。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;body hx-ext=&quot;sse&quot;&amp;gt;
    &amp;lt;div id=&quot;grid&quot; sse-connect=&quot;/stream&quot; sse-swap=&quot;snapshot&quot;&amp;gt;
        {{ cards|safe }}
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;htmx 的 SSE 扩展维护一条到 &lt;code&gt;/stream&lt;/code&gt; 的服务端推送连接。&lt;code&gt;sse-swap=&quot;snapshot&quot;&lt;/code&gt; 声明的是：服务器会推送一个名为 &lt;code&gt;snapshot&lt;/code&gt; 的事件，事件的数据体就是要替换进 &lt;code&gt;#grid&lt;/code&gt; 的 HTML。所以服务器这一侧推送的&lt;strong&gt;不是 JSON 数据，而是已经渲染好的卡片 HTML&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;event: snapshot
data: &amp;lt;div id=&quot;grid&quot;&amp;gt;...完整的卡片 HTML...&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端拿到就 swap。当实时数据只是&quot;服务器状态的投影&quot;时，渲染这件事应该发生在拥有状态的那一侧——服务器——而不是在一个需要先重建状态的浏览器里再做一遍。这样你就&lt;strong&gt;少了一层客户端渲染&lt;/strong&gt;：不再有&quot;收到 JSON → 更新 store → 触发重渲染&quot;这条链路，只有&quot;收到 HTML → 替换 DOM&quot;。&lt;/p&gt;
&lt;p&gt;代价在服务器这一侧：你得管理这条长连接的生命周期——谁在听、连接断了要不要清掉、数据一变怎么广播给所有监听的客户端。这听起来吓人，但本质就是一个&quot;订阅者列表 + 一个广播循环&quot;，是每个后端框架都能做的老把戏，几十行就够（生产上当然还要处理慢消费者、断线重连的雪崩这些边角，但那是另一个话题）。而且它只做&lt;strong&gt;单向&lt;/strong&gt;——数据从服务器流向浏览器，任何客户端主动发起的命令（比如编辑一张卡片）仍然走普通的 HTTP POST，实时通道不承担&quot;往回传指令&quot;的职责。&lt;/p&gt;
&lt;h3&gt;为什么是 SSE 而不是 WebSocket&lt;/h3&gt;
&lt;p&gt;因为实时更新在这个应用里是&lt;strong&gt;单向&lt;/strong&gt;的——数据从服务器流向浏览器，浏览器几乎不往回发指令。SSE 就是为这个形状设计的：它是 HTTP 之上的一条长连接，&lt;code&gt;EventSource&lt;/code&gt; 会自动重连，连接天然携带同源 cookie，所以它能无缝地复用你现有的 HTTP 鉴权——一个已经登录的用户，它的会话 cookie 会自动跟着 SSE 连接走，不需要额外的认证步骤。&lt;/p&gt;
&lt;p&gt;WebSocket 的代价在于，它把&quot;双向&quot;这个你根本用不上的能力，作为你必须支付的成本塞了回来：断了不会自动重连，你要自己实现重试和心跳；它虽然同样是从 HTTP 握手升级而来（所以也能带 cookie），但它更像一条独立的双向管道，和&quot;请求-响应-推送&quot;这套 HTTP 生命周期是两张皮。当你只需要一条单向的推送流时，WebSocket 是杀鸡用牛刀。&lt;/p&gt;
&lt;h3&gt;诚实一点：DOM 也是状态&lt;/h3&gt;
&lt;p&gt;我在第二节说&quot;状态只有一个&quot;。这句话被理想化了。&lt;/p&gt;
&lt;p&gt;因为 DOM 本身就是第二份状态。用户正在一张卡片里填表单、展开了一个下拉框、焦点停在某个输入框里——这些都不是服务器知道的事，但它们是真真切切、用户正在持有的状态。这时候服务器推来一个 &lt;code&gt;snapshot&lt;/code&gt;，把整个 &lt;code&gt;#grid&lt;/code&gt; 换掉，用户的输入、展开的菜单、焦点，全都没了。比一张&quot;过期的卡片&quot;糟糕得多。&lt;/p&gt;
&lt;p&gt;所以并发写入、多标签页、后退按钮这些&quot;隐性的双状态&quot;问题，并没有在服务端渲染架构里消失——它们只是换了形态。诚实一点的说法是：状态同步这件事，从&quot;框架帮你管&quot;变成了&quot;你自己用 HTTP 原语管&quot;。SSE 事件、&lt;code&gt;hx-swap&lt;/code&gt;、OOB 补丁，就是你的原语；而&quot;什么时候该让推送让步于用户正在做的事&quot;，是你的责任，框架不会替你判断。&lt;/p&gt;
&lt;p&gt;我们是这样管的：&lt;strong&gt;swap 的时候，跳过用户正在编辑的卡片。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 一张卡片&quot;正在被编辑&quot;，如果满足任一条件：
def card_is_editing(card):
    focus = document.active_element
    if focus 落在 card 内，且 focus 是输入框（input / textarea / select）:
        return true
    if card 内有一个打开的编辑表单:
        return true
    return false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是每条 &lt;code&gt;card&lt;/code&gt; 事件到达时，先检查目标卡片是否在编辑——是，就 &lt;code&gt;preventDefault()&lt;/code&gt;，跳过这次 swap，让那一秒的更新作废。而整网的 &lt;code&gt;snapshot&lt;/code&gt; 则更谨慎：只要有&lt;strong&gt;任何一张&lt;/strong&gt;卡片在编辑，就把这次 snapshot 暂存起来，等编辑结束再应用——代价是编辑期间整张网格都跟着停在旧状态。广播是一秒一拍，所以在编辑的卡片不会永远停在旧状态：编辑一结束，下一拍就带来一张新鲜的。&lt;/p&gt;
&lt;p&gt;你确实要为此写一小段 JavaScript，专门在&quot;实时推送&quot;和&quot;用户在做的交互&quot;之间做仲裁。它不优雅，但它诚实——它承认了 DOM 是状态，然后显式地决定这两份状态谁在什么时候让谁。这正是这套架构真正的样子：没有魔法，只有把 HTTP 原语一件一件摆清楚。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. 岛屿：当一块页面真的需要&quot;活着&quot;&lt;/h2&gt;
&lt;p&gt;写到这里，一个读者会问：这一切都很好，但我要一个真正的交互式组件怎么办——带标签切换、能实时刷新的图表，那种点一下 tab 就换一组数据、鼠标划过能看 tooltip 的东西？htmx 的局部替换能处理表单和列表，但处理不了&quot;浏览器里有一个需要自己管理状态的 widget&quot;。&lt;/p&gt;
&lt;p&gt;答案是承认它，然后给它划出一块小小的、自包含的&quot;岛屿&quot;（island）。这不是对前面论证的背叛，而是把那条界线画清楚。&lt;/p&gt;
&lt;h3&gt;大多数页面是服务器渲染的，少数不是&lt;/h3&gt;
&lt;p&gt;在我的仪表盘里，绝大多数页面遵循同一套规则：服务器渲染 HTML，htmx 负责局部更新。但有一处例外——参与者的历史详情页，里面有一张图表。它有&quot;本次会话 / 跨会话&quot;两个 tab、一个实时跳动的帧计数、一条汇总行，还有一张 Chart.js 画的折线图。&lt;/p&gt;
&lt;p&gt;这个 widget 需要一种服务器渲染很难提供的东西：&lt;strong&gt;客户端状态&lt;/strong&gt;。用户点 tab，图表切换数据源，但服务器并不需要知道&quot;当前显示哪个 tab&quot;——这是纯呈现层的状态，和服务器无关。强行用 htmx 去做，每切一次 tab 都要服务器往返一次，为了一个本地 UI 状态去打断用户体验，得不偿失。&lt;/p&gt;
&lt;p&gt;所以这张图表是一座岛屿：页面上一个自包含、带自己运行时的小块，周围的一切仍然是服务器渲染的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    Page[&quot;详情页（服务器渲染）&quot;]
    Page --&amp;gt; Header[&quot;头部与静态字段&quot;]
    Page --&amp;gt; Panel[&quot;#detail-panel 表单区&amp;lt;br/&amp;gt;（htmx 局部替换）&quot;]
    Page --&amp;gt; Island[&quot;历史图表 island&amp;lt;br/&amp;gt;（petite-vue + Chart.js）&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div
    v-scope=&quot;HistoryChart({ pid: 42, cutoff: 0.6 })&quot;
    @vue:mounted=&quot;start&quot;
    @vue:unmounted=&quot;stop&quot;
&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;session&apos;)&quot; :disabled=&quot;tab === &apos;session&apos;&quot;&amp;gt;This session&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;all&apos;)&quot; :disabled=&quot;tab === &apos;all&apos;&quot;&amp;gt;Across sessions&amp;lt;/button&amp;gt;
    &amp;lt;span v-if=&quot;headerCode&quot;&amp;gt;{{ headerCode }} · {{ frameCount }} frames&amp;lt;/span&amp;gt;
    &amp;lt;canvas&amp;gt;&amp;lt;/canvas&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里我用的是 petite-vue——一个 6KB、无构建步骤的迷你 Vue，专为&quot;给一小块 HTML 加上反应性&quot;而生。它不是第二套完整框架，只是一个让岛屿内部能响应用户输入的小工具。&lt;/p&gt;
&lt;h3&gt;岛屿仍然尊重核心原则&lt;/h3&gt;
&lt;p&gt;岛屿里可以有客户端状态，但&lt;strong&gt;数据仍然来自服务器&lt;/strong&gt;。这张图表初始化时用同源 fetch 拉历史记录，带的是现有的会话 cookie——鉴权没有任何额外复杂度。而它的实时性走的是上一节讲的那条 EventSource（SSE）——同一条流上承载着多个命名事件：网格收到 &lt;code&gt;snapshot&lt;/code&gt; 去换 HTML，岛屿收到 &lt;code&gt;card&lt;/code&gt; 就重新拉取自己的数据。数据仍然只有一个来源，岛屿只是那个来源的一个活跃视图。&lt;/p&gt;
&lt;p&gt;这是一条分界线：岛屿持有的是&lt;strong&gt;短暂的、呈现层的状态&lt;/strong&gt;——哪个 tab 被选中、图表当前画到哪一帧。服务器的状态——记录本身、质量阈值、哪些帧有效——仍然是唯一的事实来源，仍然由服务器拥有。你引进了一个小小的运行时，但没有引进第二份需要同步的真相。&lt;/p&gt;
&lt;h3&gt;岛屿必须住在 swap 之外&lt;/h3&gt;
&lt;p&gt;岛屿还有一个容易踩的纪律：&lt;strong&gt;它不能待在 htmx 的 swap 目标里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;原因在挂载时机。petite-vue 的 &lt;code&gt;createApp().mount()&lt;/code&gt; 只在页面加载时执行一次，它编译的是那一刻 DOM 里已经存在的 &lt;code&gt;[v-scope]&lt;/code&gt; 元素。而 htmx 是之后才把新 HTML swap 进页面的——如果这段 swap 进来的 HTML 里含有一个 &lt;code&gt;v-scope&lt;/code&gt;，petite-vue 根本不知道它的存在，它只是一堆不会被响应式化的死标签。想让它活过来，你就得在每个 swap 之后手动重新挂载，同时还得自己处理上一次挂载的销毁、事件监听和 EventSource 的释放。这条路一旦走进去，你就把两套运行时缝合在了一起，复杂度会报复性地涨回来。&lt;/p&gt;
&lt;p&gt;所以实际操作里，swap 目标和岛屿在 DOM 里是&lt;strong&gt;并列的兄弟，而不是嵌套的父子&lt;/strong&gt;。回到那张详情页：上面是一个 &lt;code&gt;#detail-panel&lt;/code&gt; 的 swap 目标，里面是纯表单——设置区间、分配动作，这些走 htmx 局部替换；下面是一张图表的 island，它不靠 htmx 换，而是靠自己的同源 fetch 加 EventSource 刷新自己。两条更新通道各管各的，互不嵌套。&lt;/p&gt;
&lt;p&gt;这条纪律和上一节是同一件事的两个面：岛屿的数据不来自 htmx 的响应体，而来自它自己拉的那条线。正因为这样，它才能安心地待在 swap 之外，不需要被任何局部替换连根拔起。&lt;/p&gt;
&lt;h3&gt;一个熟悉的代价&lt;/h3&gt;
&lt;p&gt;岛屿不是免费的。你重新引入了客户端状态、一个手写的 JS 文件、以及第二个运行时（petite-vue）。&quot;SPA 的成本&quot;回来了，只是被严格限制在了一个几十行的小方块里，而不是弥漫到整个应用。&lt;/p&gt;
&lt;p&gt;还有那种只有踩过才会知道的坑。petite-vue 会把作用域里的每个属性深度包装成 Proxy 来做反应性，这通常很好；但如果你把 Chart.js 的实例或 EventSource 放进响应式作用域，它们的内部槽位会被 Proxy 破坏，方法调用直接报错。解法是把这些实例放在闭包局部变量里，让它们对反应性系统不可见。这和前面那个 Latin-1 响应头是同一类经验：你搏斗的对象从&quot;框架的抽象&quot;变成了&quot;某个具体机制的边界&quot;，而后者一旦理解，就再也不会出错。&lt;/p&gt;
&lt;h3&gt;岛屿架构，而不是&quot;全有或全无&quot;&lt;/h3&gt;
&lt;p&gt;如果你熟悉 Astro，你会在&quot;岛屿架构&quot;这个词里认出这个模式。区别只是：Astro 把它做成了框架的一等公民，而这里是手搓的、更小的一撮。两者背后的判断是同一个——&lt;strong&gt;决定哪些东西需要客户端运行时，是一个一个组件做的，而不是整个应用一起做的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;最重要的判断，其实是这个：htmx 的价值从来不在&quot;拒绝 JavaScript&quot;，而在于&quot;把 JavaScript 的使用推迟到真正需要它的地方&quot;。绝大多数交互——一个表单、一次列表刷新、一个错误提示——用 HTML 和 HTTP 就够；只有当你遇到一个真正需要自己管理状态的 widget 时，才划出一座岛屿。默认服务器渲染，按需引入运行时，而不是反过来。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;8. 什么时候这个模式是对的&lt;/h2&gt;
&lt;p&gt;我不想把它写成一封 htmx 的劝退信或安利信。它是工具，工具要放在对的场景里。这张清单是我的判断：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这个模式适合你，当：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;应用本质上是&quot;服务器状态的多个视图&quot;——仪表盘、后台管理、内容展示。&lt;/li&gt;
&lt;li&gt;表单密集、CRUD 主导。&lt;/li&gt;
&lt;li&gt;实时性以推送为主，不在客户端做复杂交互。&lt;/li&gt;
&lt;li&gt;团队后端强于前端，或者你根本不想维护两套代码和两条构建链。&lt;/li&gt;
&lt;li&gt;你希望&quot;登出就真的登出了&quot;，&quot;状态就真的只有一份&quot;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;这个模式不适合你，当：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要离线优先。&lt;/li&gt;
&lt;li&gt;客户端有复杂的本地状态机（拖拽、富文本编辑、协作编辑）。&lt;/li&gt;
&lt;li&gt;页面本身就是一个本地应用，而不是服务器状态的投影。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这些场景里，浏览器确实是一个运行时，SPA 就是正解。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;结语：那个心智模型一直是对的&lt;/h2&gt;
&lt;p&gt;ASPX 和 PHP 那一代人不是笨，只是合适的工具还没有发展出来。他们的直觉——&lt;strong&gt;服务器负责状态，HTML 负责表达&lt;/strong&gt;——从头到尾都没错，错的是他们手里的工具：没有类型安全的模板，没有声明式的局部更新，没有真正可用的 CSS 系统，没有一条干净的实时推送通道。&lt;/p&gt;
&lt;p&gt;htmx 补上了局部更新，模板引擎补上了类型安全，Tailwind 补上了样式，SSE 补上了实时。当这些工具到齐了，那个旧的心智模型突然又变得现代了——不是因为世界倒退回了 2005 年，而是因为&quot;服务器懂页面&quot;这个想法，一直在那里等着被重新兑现。&lt;/p&gt;
&lt;p&gt;如果你也曾经在 &lt;code&gt;node_modules&lt;/code&gt; 的深处、在两条路由表之间、在缓存失效的泥潭里感到过疲惫，也许值得回头看一眼。你会发现，你并不是在往后退，而是在找回一条本来就对的路。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;进一步阅读&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;《Hypermedia Systems》——Carson Gross、Adam Stepinski、Denis Pashevsky 著，免费在线阅读：hypermedia.systems。这本书是上面所有想法的系统论述，比这篇文章完整得多。&lt;/li&gt;
&lt;li&gt;htmx 官网的 essays 专栏（htmx.org/essays）——&quot;超媒体作为应用状态的引擎&quot;（HATEOAS）、什么时候该用超媒体、什么时候该用 SPA，那里有更严谨的讨论。&lt;/li&gt;
&lt;li&gt;htmx 官方文档（htmx.org/docs）——本文提到的 &lt;code&gt;hx-swap&lt;/code&gt;、&lt;code&gt;HX-Trigger&lt;/code&gt;、SSE 扩展等所有属性的权威参考。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>物理笔记：一份 (略微冗长的 划掉） 简短的阻抗介绍</title><link>https://blogs.ansonate.ink/posts/a-brief-introduction-on-impedance/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/a-brief-introduction-on-impedance/</guid><description>一份（略微冗长的）阻抗介绍：从复数表示到滤波与谐振电路。</description><pubDate>Thu, 07 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近在学习电子设计，发现阻抗一词（impedance）出现得非常频繁，但之前在高中物理中却几乎没有涉及，因此决定写一篇笔记来整理一下阻抗相关的内容。&lt;/p&gt;
&lt;h2&gt;1. 写在前面：回顾电阻 (Resistance $R$)&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;电阻是导体对直流电的阻碍作用，其大小与导体的电阻率、长度和横截面积有关。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;电阻概念的历史起源&lt;/h3&gt;
&lt;p&gt;电阻的概念可以追溯到19世纪初电学研究的早期。在1827年，德国物理学家格奥尔格·欧姆（Georg Ohm）通过一系列精巧的实验，发现了电压、电流和电阻之间的关系，这便是我们今天所熟知的欧姆定律。&lt;/p&gt;
&lt;p&gt;欧姆的实验装置相对简单：他使用伏打电堆作为电源，通过不同长度的导线，并使用扭秤测量电流的磁效应（当时尚无直接的电流测量仪器）。通过系统地改变导线长度和材料，欧姆发现电流与电压成正比，与导线长度成反比，这一发现奠定了电路理论的基础。&lt;/p&gt;
&lt;h3&gt;电阻是什么&lt;/h3&gt;
&lt;p&gt;从微观角度看，电阻的本质源于&lt;strong&gt;电子在导体中运动时与原子晶格（Lattice）的碰撞&lt;/strong&gt;。当电场施加在导体两端时，自由电子受到电场力的作用而定向移动，形成电流。然而，这种碰撞阻碍了电子的自由运动，从而产生了电阻效应。&lt;/p&gt;
&lt;p&gt;电阻的单位是欧姆 (Ohm, $\Omega$)，其与电压和电流的关系为欧姆定律：&lt;/p&gt;
&lt;p&gt;$$
V = IR \tag{1.1}
$$&lt;/p&gt;
&lt;p&gt;其中 $V$ 是电压，$I$ 是电流，$R$ 是电阻。&lt;/p&gt;
&lt;h3&gt;电阻的影响因素&lt;/h3&gt;
&lt;p&gt;电阻值的大小取决于以下几个因素：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;材料特性&lt;/strong&gt;：不同材料的电阻率不同，如银、铜、铝等良导体的电阻率较低，而橡胶、玻璃等绝缘体的电阻率极高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;几何形状&lt;/strong&gt;：电阻与导体的长度成正比，与横截面积成反比，这一关系可表示为：&lt;/p&gt;
&lt;p&gt;$$
R = \rho \frac{L}{A} \tag{1.2}
$$&lt;/p&gt;
&lt;p&gt;其中 $\rho$ 是电阻率，$L$ 是长度，$A$ 是横截面积。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;温度&lt;/strong&gt;：对于大多数金属导体，电阻随温度升高而增大（由于晶格振动加剧导致电子碰撞变得更加频繁）；而对于半导体和电解质，电阻可能随温度升高而减小。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在理想情况下，电阻值不随电压或电流的变化而变化，这种电阻称为线性电阻。然而，在实际应用中，许多元件的电阻会随工作条件的变化而改变。&lt;/p&gt;
&lt;p&gt;电阻的概念为我们理解电路中的能量耗散提供了基础，但它只描述了电路行为的一个方面。当我们从直流电转向交流电时，电路的行为变得更加复杂，我们需要引入新的概念来全面描述电路的特性。&lt;/p&gt;
&lt;h2&gt;2. 从直流到交流：电路行为的演变&lt;/h2&gt;
&lt;h3&gt;直流电 (DC) 的特性&lt;/h3&gt;
&lt;p&gt;直流电（Direct Current, DC）是我们最先接触的电学概念。在直流电路中，电流的大小和方向保持恒定，不会随时间变化。这种稳定性使得直流电路的分析相对简单，欧姆定律足以描述大多数情况下的电路行为。&lt;/p&gt;
&lt;p&gt;直流电的特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;电流方向恒定&lt;/li&gt;
&lt;li&gt;电流大小恒定&lt;/li&gt;
&lt;li&gt;只需考虑电阻对电流的阻碍作用&lt;/li&gt;
&lt;li&gt;能量主要以热能形式消耗在电阻上&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;交流电 (AC) 的复杂性&lt;/h3&gt;
&lt;p&gt;交流电（Alternating Current, AC）则完全不同，它的大小和方向随时间周期性变化。最常见的交流电是正弦波交流电，其数学表达式为：&lt;/p&gt;
&lt;p&gt;$$
I(t) = I_0 \sin(\omega t + \phi) \tag{2.1}
$$&lt;/p&gt;
&lt;p&gt;其中 $I_0$ 是电流幅值，$\omega$ 是角频率，$t$ 是时间，$\phi$ 是初相位。&lt;/p&gt;
&lt;p&gt;交流电的引入使电路分析变得复杂，因为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;时变性&lt;/strong&gt;：电压和电流随时间变化，需要考虑瞬时值和有效值&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;方向性&lt;/strong&gt;：电流方向周期性反转，改变了电磁场的分布&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;频率依赖性&lt;/strong&gt;：电路元件的行为随频率变化而不同&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;相位关系&lt;/strong&gt;：电压和电流之间可能出现相位差&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;交流电中的特殊现象&lt;/h3&gt;
&lt;p&gt;在交流电路中，我们观察到了一些直流电路中不存在的现象：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;电磁感应&lt;/strong&gt;：变化的电流产生变化的磁场，进而产生感应电动势&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电容效应&lt;/strong&gt;：变化的电压导致电容器极板上的电荷积累和释放&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;趋肤效应&lt;/strong&gt;：高频电流倾向于在导体表面流动，有效横截面积减小&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;辐射效应&lt;/strong&gt;：高频交流电会以电磁波形式辐射能量&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些现象的出现，使得简单的欧姆定律不再足以描述交流电路中的全部行为。我们需要引入更复杂的概念——阻抗，来全面描述交流电路中的电压-电流关系。&lt;/p&gt;
&lt;p&gt;然而，在深入探讨阻抗概念之前，我们需要先了解交流电路中的两个关键元件：电感和电容。正是这两个元件的存在，使得交流电路表现出与直流电路完全不同的特性，也使得阻抗概念变得必要。&lt;/p&gt;
&lt;h2&gt;3. 电感与电容：交流电路中的特殊元件&lt;/h2&gt;
&lt;h3&gt;电感的物理原理&lt;/h3&gt;
&lt;p&gt;电感（Inductance）是电路中一种重要的储能元件，它的存在源于电磁感应现象。1831年，英国科学家迈克尔·法拉第（Michael Faraday）发现了电磁感应定律，为电感现象奠定了理论基础。&lt;/p&gt;
&lt;h4&gt;电感的物理本质&lt;/h4&gt;
&lt;blockquote&gt;
&lt;p&gt;自感现象：当电流通过导体时，会在导体周围产生磁场。如果电流发生变化，磁场也会随之变化，这种变化的磁场会在导体中产生感应电动势，这个感应电动势的方向总是阻碍电流的变化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;电感的数学表达式为：&lt;/p&gt;
&lt;p&gt;$$
V_L = L \frac{\mathrm dI}{\mathrm dt} \tag{3.1}
$$&lt;/p&gt;
&lt;p&gt;其中 $v_L$ 是电感两端的电压，$L$ 是电感量，$\frac{\mathrm dI}{\mathrm dt}$ 是电流变化率。&lt;/p&gt;
&lt;p&gt;这个公式告诉我们，由于自感现象，电感两端的电压与电流的变化率成正比，而不是与电流本身成正比。这正是电感与电阻的根本区别：&lt;strong&gt;电阻阻碍电流的流动，而电感阻碍电流的变化&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果电阻对应物理世界中的摩擦力，那么电感则对应于物理世界中带有惯性（inertia）的物体。电感元件在电路中储存和释放能量，其能量形式为磁场能。&lt;/p&gt;
&lt;h4&gt;电感在交流电路中的行为&lt;/h4&gt;
&lt;p&gt;在交流电路中，电流随时间正弦变化：&lt;/p&gt;
&lt;p&gt;$$
I(t) = I_0 \sin(\omega t) \tag{3.2}
$$&lt;/p&gt;
&lt;p&gt;根据电感的电压公式 $(3.1)$，我们可以得到：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot \frac{\mathrm d}{\mathrm dt}[I_0 \sin(\omega t)] = L \cdot \omega I_0 \cos(\omega t) = \omega L I_0 \sin\left(\omega t + \frac{\pi}{2}\right) \tag{3.3}
$$&lt;/p&gt;
&lt;p&gt;这表明电感两端的电压超前电流 $\dfrac{\pi}{2}$。这种相位差是交流电路中电感的特征行为。&lt;/p&gt;
&lt;p&gt;电感对交流电的阻碍作用称为感抗（Inductive Reactance），其大小为：&lt;/p&gt;
&lt;p&gt;$$
X_L = \omega L = 2\pi fL \tag{3.4}
$$&lt;/p&gt;
&lt;p&gt;感抗随频率的增加而增加，因此电感对高频信号的阻碍作用更强&lt;/p&gt;
&lt;h3&gt;电容的物理原理&lt;/h3&gt;
&lt;p&gt;电容（Capacitance）是另一种重要的储能元件，它的历史可以追溯到18世纪中叶，当时荷兰科学家彼得·范·穆森布鲁克（Pieter van Musschenbroek）发明了莱顿瓶，这是最早的电容器。&lt;/p&gt;
&lt;h4&gt;电容的物理本质&lt;/h4&gt;
&lt;p&gt;电容是由两个相互绝缘的导体（极板）组成的系统。当在两极板间施加电压时，正电荷会聚集在一个极板上，负电荷会聚集在另一个极板上，形成电场。&lt;/p&gt;
&lt;p&gt;电容的定义为：&lt;/p&gt;
&lt;p&gt;$$
C = \frac{Q}{V} \tag{3.5}
$$&lt;/p&gt;
&lt;p&gt;其中 $C$ 是电容量，$Q$ 是极板上的电荷量，$V$ 是两极板间的电压&lt;/p&gt;
&lt;p&gt;电容的 $I-V$ 关系为：&lt;/p&gt;
&lt;p&gt;$$
I_C = C \frac{\mathrm dV}{\mathrm dt} \tag{3.6}
$$&lt;/p&gt;
&lt;p&gt;这表明电容中的电流与电压的变化率成正比，即充放电过程，而不是与电压本身成正比。这是电容与电阻的根本区别。&lt;/p&gt;
&lt;p&gt;如果将电阻比作物理世界中的摩擦力，那么电容则对应于物理世界中的弹性（elasticity）。电容元件在电路中储存和释放能量，其能量形式为电场能&lt;/p&gt;
&lt;h4&gt;电容在交流电路中的行为&lt;/h4&gt;
&lt;p&gt;在交流电路中，电压随时间正弦变化：&lt;/p&gt;
&lt;p&gt;$$
V(t) = V_0 \sin(\omega t) \tag{3.7}
$$&lt;/p&gt;
&lt;p&gt;根据电容的电流公式，我们可以得到：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \frac{\mathrm d}{\mathrm dt}[V_0 \sin(\omega t)] = C \cdot \omega  V_0 \cos(\omega t) = \omega C V_0 \sin\left(\omega t + \frac{\pi}{2}\right) \tag{3.8}
$$&lt;/p&gt;
&lt;p&gt;这表明电容中的电流超前电压 $\dfrac{\pi}{2}$，与电感的情况正好相反。&lt;/p&gt;
&lt;p&gt;电容对交流电的阻碍作用称为容抗（Capacitive Reactance），其大小为：&lt;/p&gt;
&lt;p&gt;$$
X_C = \frac{1}{\omega C} = \frac{1}{2\pi fC} \tag{3.9}
$$&lt;/p&gt;
&lt;p&gt;容抗随频率的增加而减小，因此&lt;strong&gt;电容对高频信号的阻碍作用较弱，对低频信号的阻碍作用较强&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;电感与电容的对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;电感&lt;/th&gt;
&lt;th&gt;电容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;储能形式&lt;/td&gt;
&lt;td&gt;磁场能&lt;/td&gt;
&lt;td&gt;电场能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;电压-电流关系&lt;/td&gt;
&lt;td&gt;$V_L = L \dfrac{\mathrm dI}{\mathrm dt}$&lt;/td&gt;
&lt;td&gt;$I_C = C \dfrac{\mathrm dV}{\mathrm dt}$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;相位关系&lt;/td&gt;
&lt;td&gt;电压超前电流 $\dfrac{\pi}{2}$&lt;/td&gt;
&lt;td&gt;电流超前电压 $\dfrac{\pi}{2}$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;频率特性&lt;/td&gt;
&lt;td&gt;感抗随频率增加而增加&lt;/td&gt;
&lt;td&gt;容抗随频率增加而减小&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;直流特性&lt;/td&gt;
&lt;td&gt;对直流相当于短路&lt;/td&gt;
&lt;td&gt;对直流相当于开路&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;电感和电容的这些特性，使它们在交流电路中表现出与电阻完全不同的行为，也为我们理解阻抗概念奠定了基础。&lt;/p&gt;
&lt;p&gt;现在我们已经了解了电阻、电感和电容的基本特性，是时候将它们统一在一个框架下了。阻抗概念正是为了描述这些元件在交流电路中的综合行为而引入的，它不仅包含了能量耗散，也包含了能量存储和相位关系。&lt;/p&gt;
&lt;h2&gt;4. 关于阻抗：电阻，但不只是电阻&lt;/h2&gt;
&lt;h3&gt;什么是阻抗 (Impedance $Z$) ？&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;阻抗（Electrical Impedance）又称电阻抗，是电路中电阻、电感、电容对交流电的阻碍作用的统称&lt;/p&gt;
&lt;p&gt;- From &lt;a href=&quot;https://zh.wikipedia.org/zh/%E9%98%BB%E6%8A%97&quot;&gt;wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;阻抗由以下部份组成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    Z[阻抗 Z] --&amp;gt; R[电阻 R：阻碍直流]
    Z --&amp;gt; X[电抗 X：阻碍交流]
    X --&amp;gt; XL[感抗 Xₗ：线圈反抗电流变化]
    X --&amp;gt; XC[容抗 Xc：电容反抗电压变化]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$$
Z = R + jX
$$&lt;/p&gt;
&lt;p&gt;$$
X_l = 2\pi fL
$$&lt;/p&gt;
&lt;p&gt;$$
X_c = \frac{1}{2\pi fC}
$$&lt;/p&gt;
&lt;h3&gt;阻抗的数学推导与复数表示&lt;/h3&gt;
&lt;h4&gt;时域与频域：两种不同的视角&lt;/h4&gt;
&lt;p&gt;在深入探讨阻抗的数学表示之前，我们需要理解两个重要的概念：时域和频域。这两个概念为我们提供了分析电路的两种不同视角，每种视角都有其独特的优势。&lt;/p&gt;
&lt;h5&gt;什么是时域？&lt;/h5&gt;
&lt;p&gt;时域是我们最熟悉的视角，它描述信号如何随时间变化。在时域中，我们观察的是电压、电流等物理量在每一时刻的值。就像我们用秒表记录运动物体的位置一样，时域分析关注的是&quot;在时间t时刻，信号值是多少&quot;。&lt;/p&gt;
&lt;p&gt;在之前的章节中，我们已经接触了时域的表示方法。例如，交流电的电流可以表示为：&lt;/p&gt;
&lt;p&gt;$$
I(t) = I_0 \sin(\omega t + \phi)
$$&lt;/p&gt;
&lt;p&gt;这个公式告诉我们，在任何时刻 $t$，电流的值是多少。时域分析的优点是直观、易于理解，因为我们日常经验就是基于时间的。&lt;/p&gt;
&lt;p&gt;然而，时域分析也有其局限性。当电路中包含电感和电容时，我们需要处理微分方程，这使得计算变得复杂。此外，时域分析难以直接看出电路对不同频率信号的响应特性。&lt;/p&gt;
&lt;h5&gt;什么是频域？&lt;/h5&gt;
&lt;p&gt;频域是另一种视角，它描述信号包含哪些频率成分，以及每个频率成分的强度和相位。如果说时域关注的是&quot;信号在时间上如何变化&quot;，那么频域关注的是&quot;信号由哪些频率组成&quot;。&lt;/p&gt;
&lt;p&gt;频域分析就像是用棱镜将白光分解成彩虹中的各种颜色。白光本身看起来是单一的，但实际上它包含了多种不同频率（颜色）的光。同样，一个复杂的时域信号也可以被分解为多个不同频率的正弦波的叠加。&lt;/p&gt;
&lt;p&gt;频域分析的数学基础是傅里叶变换，它可以将时域信号转换为频域表示。对于我们常见的正弦波交流电，其频域表示非常简单：只在特定频率f处有一个幅值。&lt;/p&gt;
&lt;h5&gt;为什么需要频域分析？&lt;/h5&gt;
&lt;p&gt;频域分析在交流电路分析中特别有用，原因如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;简化计算&lt;/strong&gt;：在频域中，微分运算变成了简单的乘法运算。例如，电感的电压-电流关系 $V_L = L \dfrac{\mathrm dI}{\mathrm dt}$ 在频域中变成了 $V = j\omega L I$，这是一个简单的代数方程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;直观理解频率响应&lt;/strong&gt;：频域分析使我们能够直接看出电路对不同频率信号的响应。例如，我们可以一眼看出一个电路是低通滤波器、高通滤波器还是带通滤波器。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;统一分析框架&lt;/strong&gt;：频域分析为电阻、电感和电容提供了一个统一的分析框架，使我们能够用相同的方法处理这些不同的元件。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;简化复杂信号分析&lt;/strong&gt;：对于复杂的非正弦信号（如方波、三角波等），频域分析可以将它们分解为多个正弦波的叠加，然后分别分析每个频率成分通过电路的情况。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h5&gt;时域与频域的对应关系&lt;/h5&gt;
&lt;p&gt;时域和频域是描述同一信号的两种不同方式，它们之间有着严格的数学对应关系。以下是几个常见信号的时域和频域对应关系：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;信号类型&lt;/th&gt;
&lt;th&gt;时域表示&lt;/th&gt;
&lt;th&gt;频域表示&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;直流信号&lt;/td&gt;
&lt;td&gt;常数&lt;/td&gt;
&lt;td&gt;只在频率0处有值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;正弦波&lt;/td&gt;
&lt;td&gt;$A\sin(2\pi ft + \phi)$&lt;/td&gt;
&lt;td&gt;只在频率f处有值，幅值为A，相位为$\phi$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;方波&lt;/td&gt;
&lt;td&gt;周期性方波&lt;/td&gt;
&lt;td&gt;基频和奇次谐波&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;冲激信号&lt;/td&gt;
&lt;td&gt;极短时间内的极大值&lt;/td&gt;
&lt;td&gt;所有频率都有相同的幅值&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h5&gt;从时域到频域的转变&lt;/h5&gt;
&lt;p&gt;在交流电路分析中，我们经常需要在时域和频域之间转换。这种转换不仅是一种数学技巧，更是一种思维方式的转变。&lt;/p&gt;
&lt;p&gt;例如，当我们分析一个RC低通滤波器时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在时域中，我们需要解微分方程来找出输出电压随时间的变化&lt;/li&gt;
&lt;li&gt;在频域中，我们只需要计算传递函数 $H(f) = \dfrac{1}{1 + j2\pi fRC}$，就能直接看出电路对不同频率信号的响应&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种从时域到频域的转变，使我们能够更深入地理解电路的行为特性，特别是对频率的选择性。&lt;/p&gt;
&lt;h5&gt;频域分析的实际应用&lt;/h5&gt;
&lt;p&gt;频域分析在许多领域都有广泛应用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;音频处理&lt;/strong&gt;：均衡器通过增强或衰减特定频率的信号来调整音质&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无线通信&lt;/strong&gt;：不同电台使用不同频率的信号进行广播&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图像处理&lt;/strong&gt;：图像可以通过傅里叶变换转换到频域进行处理&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;振动分析&lt;/strong&gt;：机械系统的振动可以分解为不同频率的成分&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过理解时域和频域的概念，我们为学习阻抗的复数表示打下了坚实的基础。阻抗本质上是一个频域概念，它描述了电路对不同频率信号的响应特性。在接下来的内容中，我们将看到如何利用频域分析来简化交流电路的计算，并获得更深刻的物理洞察。&lt;/p&gt;
&lt;h4&gt;为什么需要复数？一个简单的引入&lt;/h4&gt;
&lt;p&gt;在之前的章节中，我们已经了解到交流电路中的电感和电容会导致电压和电流之间产生相位差。这种相位差使得交流电路的分析比直流电路复杂得多。为了简化这种复杂性，数学家们发现了复数这样一个非常强大的工具&lt;/p&gt;
&lt;h4&gt;复数基础回顾&lt;/h4&gt;
&lt;p&gt;在深入阻抗的复数表示之前，让我们先简单回顾一下复数的基本概念：&lt;/p&gt;
&lt;p&gt;复数可以表示为 $a + jb$，其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$a$ 是实部&lt;/li&gt;
&lt;li&gt;$b$ 是虚部&lt;/li&gt;
&lt;li&gt;$j$ 是虚数单位，满足 $j^2 = -1$&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;由于 $i$ 有时表示电流，电子学中常用 $j$ 表示虚数单位，即 $j^2 = -1$&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;复数可以在平面上表示，横轴是实轴，纵轴是虚轴。这种表示方法帮助我们直观地理解复数的运算。&lt;/p&gt;
&lt;h4&gt;欧拉公式&lt;/h4&gt;
&lt;p&gt;在交流电路分析中，我们面临一个挑战：如何简化微分方程的求解？18世纪瑞士数学家莱昂哈德·欧拉（Leonhard Euler）发现了一个被誉为&quot;数学中最美公式&quot;的欧拉公式：&lt;/p&gt;
&lt;p&gt;$$
e^{j\theta} = \cos\theta + j\sin\theta \tag{4.1}
$$&lt;/p&gt;
&lt;p&gt;这个公式实际上建立了一个非常重要的桥梁：它将指数函数与三角函数联系了起来，这意味着我们可以用指数函数来表示 $\sin$ 和 $\cos$&lt;/p&gt;
&lt;p&gt;$$
\sin(\omega t) = \text{Im}[e^{j\omega t}] \tag{4.2}
$$&lt;/p&gt;
&lt;p&gt;$$
\cos(\omega t) = \text{Re}[e^{j\omega t}] \tag{4.3}
$$&lt;/p&gt;
&lt;p&gt;使用欧拉公式将复数变换至形式，微分运算将会变得非常简单：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm d}{\mathrm dt}e^{j\omega t} = j\omega e^{j\omega t} \tag{4.4}
$$&lt;/p&gt;
&lt;h4&gt;复数表示法与相量：简化交流电路分析&lt;/h4&gt;
&lt;h5&gt;什么是相量？&lt;/h5&gt;
&lt;p&gt;在交流电路分析中，我们引入了相量（Phasor）的概念。相量可以理解为&quot;旋转的箭头&quot;，它是一个复数，表示正弦量的两个关键信息：幅值（大小）和相位（位置）。&lt;/p&gt;
&lt;p&gt;相量的数学表示为：&lt;/p&gt;
&lt;p&gt;$$
V = V_0 e^{j\phi_v} \tag{4.5}
$$&lt;/p&gt;
&lt;p&gt;$$
I = I_0 e^{j\phi_i} \tag{4.5}
$$&lt;/p&gt;
&lt;p&gt;其中 $V_0$ 和 $I_0$ 是幅值（箭头的长度），$\phi_v$ 和 $\phi_i$ 是初相位（箭头的初始角度）。&lt;/p&gt;
&lt;h5&gt;相量如何简化电路分析？&lt;/h5&gt;
&lt;p&gt;使用相量表示法，我们可以将复杂的微分方程转换为简单的代数方程。让我们通过电感和电容的例子，一步一步地详细说明这个过程。&lt;/p&gt;
&lt;h6&gt;电感的例子：从微分方程到代数方程&lt;/h6&gt;
&lt;p&gt;在时域中，电感的电压-电流关系是一个微分方程：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \frac{\mathrm dI_L(t)}{\mathrm dt} \tag{4.6}
$$&lt;/p&gt;
&lt;p&gt;这个方程告诉我们，电感两端的电压与电流的变化率成正比。如果我们知道电流的具体形式，可以通过求导得到电压。&lt;/p&gt;
&lt;p&gt;让我们假设通过电感的电流是一个正弦波：&lt;/p&gt;
&lt;p&gt;$$
I_L(t) = I_0 \sin(\omega t + \phi_i) \tag{4.7}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$I_0$ 是电流的幅值（最大值）&lt;/li&gt;
&lt;li&gt;$\omega$ 是角频率（$\omega = 2\pi f$，$f$ 是频率）&lt;/li&gt;
&lt;li&gt;$t$ 是时间&lt;/li&gt;
&lt;li&gt;$\phi_i$ 是初相位（决定电流在 $t=0$ 时的值）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果我们直接在时域中求解，需要将方程 $(4.7)$ 代入方程 $(4.6)$：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \frac{\mathrm d}{\mathrm dt}[I_0 \sin(\omega t + \phi_i)]
$$&lt;/p&gt;
&lt;p&gt;求导得到：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot I_0 \omega \cos(\omega t + \phi_i) \tag{4.8}
$$&lt;/p&gt;
&lt;p&gt;利用三角恒等式 $\cos\theta = \sin(\theta + \dfrac{\pi}{2})$，我们可以将方程 $(4.8)$ 改写为：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot I_0 \omega \sin(\omega t + \phi_i + \frac{\pi}{2}) \tag{4.9}
$$&lt;/p&gt;
&lt;p&gt;这个结果表明，电感两端的电压也是一个正弦波，但相位超前电流 $\dfrac{\pi}{2}$&lt;/p&gt;
&lt;p&gt;现在，让我们看看如何使用相量表示法来简化这个过程。首先，我们需要将正弦函数表示为复指数函数的虚部。&lt;/p&gt;
&lt;p&gt;根据欧拉公式：$e^{j\theta} = \cos\theta + j\sin\theta$，我们有：&lt;/p&gt;
&lt;p&gt;$$
\sin\theta = \text{Im}[e^{j\theta}]
$$&lt;/p&gt;
&lt;p&gt;因此，方程 $(4.7)$ 可以表示为：&lt;/p&gt;
&lt;p&gt;$$
I_L(t) = I_0 \sin(\omega t + \phi_i) = \text{Im}[I_0 e^{j(\omega t + \phi_i)}] = \text{Im}[I_0 e^{j\phi_i} e^{j\omega t}] \tag{4.10}
$$&lt;/p&gt;
&lt;p&gt;在相量表示法中，我们定义电流相量 $I$ 为：&lt;/p&gt;
&lt;p&gt;$$
I = I_0 e^{j\phi_i} \tag{4.11}
$$&lt;/p&gt;
&lt;p&gt;因此，方程 $(4.10)$ 可以写为：&lt;/p&gt;
&lt;p&gt;$$
I_L(t) = \text{Im}[I e^{j\omega t}] \tag{4.12}
$$&lt;/p&gt;
&lt;p&gt;现在，让我们对方程 $(4.12)$ 两边求导：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm dI_L(t)}{\mathrm dt} = \frac{\mathrm d}{\mathrm dt}\text{Im}[I e^{j\omega t}] = \text{Im}\left[\frac{\mathrm d}{\mathrm dt}(I e^{j\omega t})\right]
$$&lt;/p&gt;
&lt;p&gt;因为 $I$ 是一个常数（不随时间变化），所以：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm dI_L(t)}{\mathrm dt} = \text{Im}[I \cdot \frac{\mathrm d}{\mathrm dt}e^{j\omega t}] = \text{Im}[I \cdot j\omega e^{j\omega t}] = \text{Im}[j\omega I e^{j\omega t}] \tag{4.13}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.13)$ 代入电感的电压方程 $(4.6)$：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot \text{Im}[j\omega I e^{j\omega t}] = \text{Im}[j\omega L I e^{j\omega t}] \tag{4.14}
$$&lt;/p&gt;
&lt;p&gt;这表明电压的相量表示为：&lt;/p&gt;
&lt;p&gt;$$
V = j\omega L I \tag{4.15}
$$&lt;/p&gt;
&lt;p&gt;从方程 $(4.15)$，我们可以定义电感的阻抗为：&lt;/p&gt;
&lt;p&gt;$$
Z_L = \frac{V}{I} = j\omega L \tag{4.16}
$$&lt;/p&gt;
&lt;p&gt;这个结果告诉我们，在相量表示法中，电感的阻抗是一个纯虚数 $j\omega L$。现在让我们验证一下这个结果是否与直接求解的结果一致。从方程(10)和(6)，我们有：&lt;/p&gt;
&lt;p&gt;$$
V = j\omega L I = j\omega L I_0 e^{j\phi_i} = \omega L I_0 e^{j(\phi_i + \frac{\pi}{2})} \tag{4.17}
$$&lt;/p&gt;
&lt;p&gt;因为 $j = e^{j\frac{\pi}{2}}$，所以 $j\omega L I_0 e^{j\phi_i} = \omega L I_0 e^{j(\phi_i + \frac{\pi}{2})}$。&lt;/p&gt;
&lt;p&gt;将方程 $(4.17)$ 转换回时域：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = \text{Im}\left[V e^{j\omega t}\right] = \text{Im}\left[\omega L I_0 e^{j(\phi_i + \frac{\pi}{2})} e^{j\omega t}\right] = \omega L I_0 \sin\left(\omega t + \phi_i + \frac{\pi}{2}\right)
$$&lt;/p&gt;
&lt;p&gt;这与我们直接求解得到的方程 $(4.9)$ 完全一致，验证了相量表示法的正确性。&lt;/p&gt;
&lt;h5&gt;电容的例子&lt;/h5&gt;
&lt;p&gt;在时域中，电容的电流-电压关系是：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \frac{\mathrm dV_C(t)}{\mathrm dt} \tag{4.18}
$$&lt;/p&gt;
&lt;p&gt;假设电容两端的电压是一个正弦波：&lt;/p&gt;
&lt;p&gt;$$
V_C(t) = V_0 \sin(\omega t + \phi_v) \tag{4.19}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.19)$ 代入方程 $(4.18)$：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \frac{\mathrm d}{\mathrm dt}[V_0 \sin(\omega t + \phi_v)] = C \cdot V_0 \omega \cos(\omega t + \phi_v)
$$&lt;/p&gt;
&lt;p&gt;利用三角恒等式，可以改写为：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \cdot V_0 \omega \sin\left(\omega t + \phi_v + \frac{\pi}{2}\right) \tag{4.20}
$$&lt;/p&gt;
&lt;p&gt;这表明，电容中的电流超前电压 $\dfrac{\pi}{2}$。&lt;/p&gt;
&lt;p&gt;将方程 $(4.19)$ 表示为复指数函数的虚部：&lt;/p&gt;
&lt;p&gt;$$
V_C(t) = V_0 \sin(\omega t + \phi_v) = \text{Im}[V_0 e^{j(\omega t + \phi_v)}] = \text{Im}[V_0 e^{j\phi_v} e^{j\omega t}] \tag{4.21}
$$&lt;/p&gt;
&lt;p&gt;定义电压相量 $V$ 为：&lt;/p&gt;
&lt;p&gt;$$
V = V_0 e^{j\phi_v} \tag{4.22}
$$&lt;/p&gt;
&lt;p&gt;因此，方程 $(4.20)$ 可以写为：&lt;/p&gt;
&lt;p&gt;$$
V_C(t) = \text{Im}[V e^{j\omega t}] \tag{4.23}
$$&lt;/p&gt;
&lt;p&gt;对方程 $(4.22)$ 两边求导：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm dV_C(t)}{\mathrm dt} = \frac{\mathrm d}{\mathrm dt}\text{Im}\left[V e^{j\omega t}\right] = \text{Im}\left[\frac{\mathrm d}{\mathrm dt}(V e^{j\omega t})\right] = \text{Im}\left[V \cdot j\omega e^{j\omega t}\right] = \text{Im}[j\omega V e^{j\omega t}] \tag{4.24}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.24)$ 代入电容的电流方程 $(4.18)$：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \cdot \text{Im}[j\omega V e^{j\omega t}] = \text{Im}[j\omega C V e^{j\omega t}] \tag{4.25}
$$&lt;/p&gt;
&lt;p&gt;这表明电流的相量表示为：&lt;/p&gt;
&lt;p&gt;$$
I = j\omega C V \tag{4.26}
$$&lt;/p&gt;
&lt;p&gt;从方程 $(4.26)$，我们可以解出 $V$：&lt;/p&gt;
&lt;p&gt;$$
V = \frac{1}{j\omega C} I \tag{4.27}
$$&lt;/p&gt;
&lt;p&gt;因此，电容的阻抗为：&lt;/p&gt;
&lt;p&gt;$$
Z_C = \frac{V}{I} = \frac{1}{j\omega C} \tag{4.28}
$$&lt;/p&gt;
&lt;p&gt;我们可以进一步简化方程 $(4.28)$。首先，注意到：&lt;/p&gt;
&lt;p&gt;$$
\frac{1}{j} = \frac{j}{j \cdot j} = \frac{j}{-1} = -j
$$&lt;/p&gt;
&lt;p&gt;因此，电容的阻抗可以写为：&lt;/p&gt;
&lt;p&gt;$$
Z_C = -j\frac{1}{\omega C} \tag{4.29}
$$&lt;/p&gt;
&lt;p&gt;让我们验证一下这个结果是否与直接求解的结果一致。从方程 $(4.27)$ 和 $(4.22)$，我们有：&lt;/p&gt;
&lt;p&gt;$$
I = j\omega C V = j\omega C V_0 e^{j\phi_v} = \omega C V_0 e^{j(\phi_v + \frac{\pi}{2})} \tag{4.30}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.20)$ 转换回时域：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = \text{Im}[I e^{j\omega t}] = \text{Im}[\omega C V_0 e^{j(\phi_v + \frac{\pi}{2})} e^{j\omega t}] = \omega C V_0 \sin(\omega t + \phi_v + \frac{\pi}{2})
$$&lt;/p&gt;
&lt;p&gt;这与我们直接求解得到的方程 $(4.20)$ 完全一致，验证了相量表示法的正确性。&lt;/p&gt;
&lt;h5&gt;相量表示法的优势&lt;/h5&gt;
&lt;p&gt;通过相量表示法，我们将微分方程转换为代数方程，大大简化了计算过程，同时相量的旋转特性帮助我们直观地理解交流电的相位关系，这为电阻、电感和电容提供了一个统一的分析框架，也使他们可以在复平面上绘制电压和电流的关系&lt;/p&gt;
&lt;h3&gt;阻抗的复数表示：统一的电路描述&lt;/h3&gt;
&lt;h4&gt;阻抗的复数形式&lt;/h4&gt;
&lt;p&gt;现在，我们可以将前面学到的知识整合起来，给出阻抗的复数表示：&lt;/p&gt;
&lt;p&gt;$$
Z = R + jX \tag{4.31}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$R$ 是电阻（实部），代表能量耗散&lt;/li&gt;
&lt;li&gt;$X$ 是电抗（虚部），代表能量存储&lt;/li&gt;
&lt;li&gt;$j$ 是虚数单位&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个复数形式完美地统一了电阻、电感和电容的特性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对于纯电阻：$Z = R$（只有实部）&lt;/li&gt;
&lt;li&gt;对于纯电感：$Z = j\omega L$（只有虚部，为正）&lt;/li&gt;
&lt;li&gt;对于纯电容：$Z = -j\dfrac{1}{\omega C}$（只有虚部，为负）&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;阻抗的几何解释&lt;/h4&gt;
&lt;p&gt;阻抗也可以在复平面上表示，这为我们提供了直观的几何理解：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph LR
    A[复平面] --&amp;gt; B[&quot;实轴：电阻R&quot;]
    A --&amp;gt; C[&quot;虚轴：电抗X&quot;]
    D[阻抗Z] --&amp;gt; E[&quot;从原点到点 R,jX 的向量&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;阻抗的极坐标形式为：&lt;/p&gt;
&lt;p&gt;$$
Z = |Z| e^{j\theta} \tag{4.32}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$|Z| = \sqrt{R^2 + X^2}$ 是阻抗的模，表示电路对交流电的总阻碍作用&lt;/li&gt;
&lt;li&gt;$\theta = \arctan\left(\dfrac{X}{R}\right)$ 是阻抗的相位角，表示电压与电流之间的相位差&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;物理意义&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;阻抗的模 $|Z|$ 类似于电阻的大小，但它不仅考虑了能量耗散，还考虑了能量存储效应。&lt;/li&gt;
&lt;li&gt;相位角 $\theta$ 告诉我们电压超前电流多少角度：
&lt;ul&gt;
&lt;li&gt;如果 $\theta &amp;gt; 0$，电压超前电流（电路呈感性）&lt;/li&gt;
&lt;li&gt;如果 $\theta &amp;lt; 0$，电压滞后电流（电路呈容性）&lt;/li&gt;
&lt;li&gt;如果 $\theta = 0$，电压与电流同相（电路呈纯阻性）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;从复数阻抗到欧姆定律&lt;/h4&gt;
&lt;p&gt;有了复数阻抗的概念，我们可以将欧姆定律推广到交流电路：&lt;/p&gt;
&lt;p&gt;$$
V = Z \cdot I \tag{4.33}
$$&lt;/p&gt;
&lt;p&gt;这个形式与直流电路中的欧姆定律 $V = R \cdot I$ 非常相似，但这里的 $V$ 和 $I$ 是相量，$Z$ 是复数阻抗。这种统一的形式大大简化了交流电路的分析！&lt;/p&gt;
&lt;h3&gt;阻抗的串联与并联：电路分析的实用工具&lt;/h3&gt;
&lt;h4&gt;阻抗的串联&lt;/h4&gt;
&lt;p&gt;当多个阻抗串联时，总阻抗等于各阻抗之和。这与电阻的串联规则完全相同：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;串联&lt;/strong&gt;：总阻抗等于各阻抗之和&lt;/p&gt;
&lt;p&gt;$$
Z_{total} = Z_1 + Z_2 + \cdots + Z_n \tag{4.34}
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：一个电阻 $R$ 和一个电感 $L$ 串联&lt;/p&gt;
&lt;p&gt;$$
Z_{total} = R + j\omega L
$$&lt;/p&gt;
&lt;p&gt;这个结果的物理意义是：电路中既有能量耗散（电阻部分），又有能量存储（电感部分），且电压超前电流。&lt;/p&gt;
&lt;h4&gt;阻抗的并联&lt;/h4&gt;
&lt;p&gt;当多个阻抗并联时，总阻抗的倒数等于各阻抗倒数之和。这也与电阻的并联规则相同：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;并联&lt;/strong&gt;：总阻抗的倒数等于各阻抗倒数之和&lt;/p&gt;
&lt;p&gt;$$
\frac{1}{Z_{total}} = \frac{1}{Z_1} + \frac{1}{Z_2} + \cdots + \frac{1}{Z_n} \tag{4.35}
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：一个电阻 $R$ 和一个电容 $C$ 并联&lt;/p&gt;
&lt;p&gt;$$
\frac{1}{Z_{total}} = \frac{1}{R} + \frac{1}{-j\frac{1}{\omega C}} = \frac{1}{R} + j\omega C
$$&lt;/p&gt;
&lt;p&gt;因此，&lt;/p&gt;
&lt;p&gt;$$
Z_{total} = \frac{1}{\frac{1}{R} + j\omega C} = \frac{R}{1 + j\omega RC}
$$&lt;/p&gt;
&lt;p&gt;这个结果表明，并联RC电路的阻抗是一个复数，其实部和虚部都与频率有关。&lt;/p&gt;
&lt;h4&gt;实际应用：为什么这些规则很重要？&lt;/h4&gt;
&lt;p&gt;阻抗的串联和并联规则在实际电路设计中非常重要，因为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;电路分析&lt;/strong&gt;：这些规则允许我们分析复杂的交流电路，将多个元件简化为一个等效阻抗。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;滤波器设计&lt;/strong&gt;：通过串联和并联不同的元件，可以设计出具有特定频率响应的滤波器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;阻抗匹配&lt;/strong&gt;：在信号传输中，需要匹配源阻抗和负载阻抗，以最大化功率传输或最小化反射。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;谐振电路&lt;/strong&gt;：通过串联或并联电感和电容，可以创建在特定频率下具有特殊行为的谐振电路。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些规则与直流电路中的电阻串联和并联规则形式相同，这使得我们可以将直流电路的分析方法直接推广到交流电路，大大简化了学习过程！&lt;/p&gt;
&lt;h3&gt;阻抗与导纳：两种互补的视角&lt;/h3&gt;
&lt;h4&gt;什么是导纳？&lt;/h4&gt;
&lt;p&gt;导纳（Admittance）是阻抗的倒数，用 $Y$ 表示：&lt;/p&gt;
&lt;p&gt;$$
Y = \frac{1}{Z} = G + jB \tag{4.36}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$G$ 是电导（实部），是电阻的倒数&lt;/li&gt;
&lt;li&gt;$B$ 是电纳（虚部），是电抗的倒数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;为什么需要导纳？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;导纳的概念为我们提供了分析电路的另一种视角。如果说阻抗描述了电路对电流的&quot;阻碍&quot;程度，那么导纳则描述了电路对电流的&quot;导通&quot;程度。&lt;/p&gt;
&lt;p&gt;在某些情况下，特别是分析并联电路时，使用导纳比使用阻抗更加方便。因为并联导纳可以直接相加，就像并联电阻的电导可以直接相加一样。&lt;/p&gt;
&lt;h4&gt;阻抗与导纳的转换&lt;/h4&gt;
&lt;p&gt;让我们看看如何将阻抗转换为导纳：&lt;/p&gt;
&lt;p&gt;给定阻抗 $Z = R + jX$，其导纳为：&lt;/p&gt;
&lt;p&gt;$$
Y = \frac{1}{Z} = \frac{1}{R + jX}
$$&lt;/p&gt;
&lt;p&gt;为了将分母中的虚数消除，我们可以将分子和分母同时乘以 $R - jX$：&lt;/p&gt;
&lt;p&gt;$$
Y = \frac{R - jX}{(R + jX)(R - jX)} = \frac{R - jX}{R^2 + X^2} = \frac{R}{R^2 + X^2} - j\frac{X}{R^2 + X^2} \tag{4.37}
$$&lt;/p&gt;
&lt;p&gt;因此，&lt;/p&gt;
&lt;p&gt;$$
G = \frac{R}{R^2 + X^2} \tag{4.38}
$$
$$
B = -\frac{X}{R^2 + X^2} \tag{4.39}
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：电感的导纳&lt;/p&gt;
&lt;p&gt;电感的阻抗为 $Z_L = j\omega L$，其导纳为：&lt;/p&gt;
&lt;p&gt;$$
Y_L = \frac{1}{j\omega L} = -j\frac{1}{\omega L}
$$&lt;/p&gt;
&lt;p&gt;这表明电感的导纳是一个纯虚数，且为负值。&lt;/p&gt;
&lt;h4&gt;导纳的实际应用&lt;/h4&gt;
&lt;p&gt;导纳的概念在以下几种情况下特别有用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;并联电路分析&lt;/strong&gt;：当多个元件并联时，总导纳等于各导纳之和：
$$
Y_{total} = Y_1 + Y_2 + \cdots + Y_n \tag{4.40}
$$
这比计算并联阻抗要简单得多。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;节点分析法&lt;/strong&gt;：在电路理论中，节点分析法使用导纳而不是阻抗，可以简化方程的建立。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;传输线理论&lt;/strong&gt;：在分析高频信号传输时，导纳是一个基本概念。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;网络分析&lt;/strong&gt;：在分析复杂网络时，导纳矩阵比阻抗矩阵更容易处理。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;阻抗与导纳的对比&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;阻抗 $Z$&lt;/th&gt;
&lt;th&gt;导纳 $Y$&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;定义&lt;/td&gt;
&lt;td&gt;电压与电流之比&lt;/td&gt;
&lt;td&gt;电流与电压之比&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单位&lt;/td&gt;
&lt;td&gt;欧姆 ($\Omega$)&lt;/td&gt;
&lt;td&gt;西门子 (S)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;串联&lt;/td&gt;
&lt;td&gt;直接相加&lt;/td&gt;
&lt;td&gt;需要转换&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;并联&lt;/td&gt;
&lt;td&gt;需要转换&lt;/td&gt;
&lt;td&gt;直接相加&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;物理意义&lt;/td&gt;
&lt;td&gt;对电流的阻碍&lt;/td&gt;
&lt;td&gt;对电流的导通&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;阻抗和导纳是描述电路特性的两种互补方法。在实际问题中，我们可以根据具体情况选择使用阻抗还是导纳，以简化计算和分析过程。&lt;/p&gt;
&lt;h3&gt;阻抗的物理意义：从数学到现实&lt;/h3&gt;
&lt;p&gt;阻抗的复数表示不仅仅是一个抽象的数学工具，它有着深刻的物理意义。让我们从不同角度来理解阻抗的物理含义。&lt;/p&gt;
&lt;h4&gt;能量视角：耗散与存储&lt;/h4&gt;
&lt;p&gt;从能量的角度看，阻抗的复数形式完美地描述了电路中能量的两种不同命运：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;实部（电阻）&lt;/strong&gt;：代表能量耗散&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当电流通过电阻时，电能被不可逆地转化为热能&lt;/li&gt;
&lt;li&gt;这种能量转化是不可逆的，符合热力学第二定律&lt;/li&gt;
&lt;li&gt;电阻越大，能量耗散越快&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;虚部（电抗）&lt;/strong&gt;：代表能量存储&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在电感中，能量以磁场形式存储&lt;/li&gt;
&lt;li&gt;在电容中，能量以电场形式存储&lt;/li&gt;
&lt;li&gt;这种能量存储是可逆的，可以被完全释放回电路&lt;/li&gt;
&lt;li&gt;电抗的大小决定了能量存储的效率&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;时间视角：即时与延迟&lt;/h4&gt;
&lt;p&gt;从时间的角度看，阻抗描述了电压和电流之间的时间关系：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;纯电阻电路&lt;/strong&gt;：电压和电流同步变化，没有时间延迟&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电感电路&lt;/strong&gt;：电压变化超前于电流变化，电感&quot;抵抗&quot;电流的变化&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电容电路&lt;/strong&gt;：电流变化超前于电压变化，电容&quot;抵抗&quot;电压的变化&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;频率视角：通与阻&lt;/h4&gt;
&lt;p&gt;从频率的角度看，阻抗描述了电路对不同频率信号的不同响应：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;电阻&lt;/strong&gt;：对所有频率的信号响应相同&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电感&lt;/strong&gt;：对高频信号阻碍大，对低频信号阻碍小&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电容&lt;/strong&gt;：对低频信号阻碍大，对高频信号阻碍小&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种频率选择性是许多电子电路（如滤波器、谐振电路）的基础。&lt;/p&gt;
&lt;h4&gt;几何视角：向量与旋转&lt;/h4&gt;
&lt;p&gt;从几何的角度看，阻抗可以在复平面上表示为一个向量：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;模&lt;/strong&gt;：向量的长度，表示电路对交流电的总阻碍作用&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;相位角&lt;/strong&gt;：向量与实轴的夹角，表示电压与电流之间的相位关系&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;实用视角：简化与统一&lt;/h4&gt;
&lt;p&gt;从实用的角度看，阻抗的复数表示为我们提供了一个统一的分析框架：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;统一描述&lt;/strong&gt;：将电阻、电感和电容统一在一个数学框架下&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;简化计算&lt;/strong&gt;：将微分方程转换为代数方程，大大简化了计算过程&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;直观理解&lt;/strong&gt;：通过复数平面上的几何表示，提供了直观的理解方式&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;广泛应用&lt;/strong&gt;：适用于从简单电路到复杂网络的各种情况&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 阻抗的实际应用&lt;/h2&gt;
&lt;h3&gt;滤波电路：频率选择性&lt;/h3&gt;
&lt;p&gt;阻抗的频率依赖性是许多电子电路的基础。滤波电路利用不同频率下阻抗的变化，实现对特定频率信号的选择性通过或阻断。&lt;/p&gt;
&lt;h4&gt;低通滤波器（Low-pass Filter）&lt;/h4&gt;
&lt;p&gt;一个简单的RC低通滤波器由一个电阻和一个电容串联组成：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/a-brief-introduction-on-impedance/Low_pass_filter.png&quot; alt=&quot;LC Circuit&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Schematic of a simple parallel LC circuit, &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:Low_pass_filter.png&quot;&gt;from wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;该电路的传递函数为：&lt;/p&gt;
&lt;p&gt;$$
H(f) = \frac{V_{out}}{V_{in}} = \frac{1}{1 + j2\pi fRC} \tag{5.1}
$$&lt;/p&gt;
&lt;p&gt;当频率 $f$ 很低时，电容的容抗 $X_C = \dfrac{1}{2\pi fC}$ 很大，相当于开路，信号几乎无衰减地通过。当频率 $f$ 很高时，容抗很小，相当于短路，信号被大幅衰减。&lt;/p&gt;
&lt;h4&gt;高通滤波器（High-pass Filter）&lt;/h4&gt;
&lt;p&gt;类似地，一个简单的高通滤波器可以通过交换电阻和电容的位置来实现：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/a-brief-introduction-on-impedance/CR_high_pass_filter.svg.png&quot; alt=&quot;High Pass Filter&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A high-pass filter, &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:CR_high_pass_filter.svg&quot;&gt;from wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;该电路的传递函数为：&lt;/p&gt;
&lt;p&gt;$$
H(f) = \frac{V_{out}}{V_{in}} = \frac{j2\pi fRC}{1 + j2\pi fRC} \tag{5.2}
$$&lt;/p&gt;
&lt;p&gt;这种滤波器允许高频信号通过，而阻断低频信号。&lt;/p&gt;
&lt;h3&gt;谐振电路（Resonant/LC Circuit）：能量交换的和谐&lt;/h3&gt;
&lt;p&gt;当电感和电容组合在一起时，会发生谐振现象。在谐振频率下，感抗和容抗相互抵消，电路呈现纯电阻特性。&lt;/p&gt;
&lt;h4&gt;串联谐振电路&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/a-brief-introduction-on-impedance/LC_parallel_simple.svg.png&quot; alt=&quot;LC Circuit&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Schematic of a simple parallel LC circuit, &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:LC_parallel_simple.svg&quot;&gt;from wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;串联谐振电路的总阻抗为：&lt;/p&gt;
&lt;p&gt;$$
Z = R + j\left(\omega L - \frac{1}{\omega C}\right) \tag{5.3}
$$&lt;/p&gt;
&lt;p&gt;谐振频率 $\omega_0$ 满足 $\omega_0 L = \frac{1}{\omega_0 C}$，即：&lt;/p&gt;
&lt;p&gt;$$
f_0 = \frac{1}{2\pi\sqrt{LC}} \tag{5.4}
$$&lt;/p&gt;
&lt;p&gt;在谐振频率下，阻抗达到最小值 $Z = R$，电流达到最大值。&lt;/p&gt;
&lt;h3&gt;阻抗匹配：最大功率传输&lt;/h3&gt;
&lt;p&gt;在电子系统中，阻抗匹配是一个重要概念。根据最大功率传输定理，当负载阻抗等于源阻抗的共轭复数时，负载可以获得最大功率。&lt;/p&gt;
&lt;p&gt;对于纯电阻电路，这意味着负载电阻应等于源电阻。对于含有电抗的电路，负载阻抗应为源阻抗的共轭复数：&lt;/p&gt;
&lt;p&gt;$$
Z_L = Z_S^*
$$&lt;/p&gt;
&lt;p&gt;这一原理在音频系统、射频电路和天线设计中都有重要应用。&lt;/p&gt;
&lt;h3&gt;实际应用&lt;/h3&gt;
&lt;h4&gt;音频系统中的分频器&lt;/h4&gt;
&lt;p&gt;在音响系统中，分频器利用阻抗的频率特性，将音频信号分配到不同的扬声器：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;低音扬声器：通过低通滤波器，接收低频信号&lt;/li&gt;
&lt;li&gt;高音扬声器：通过高通滤波器，接收高频信号&lt;/li&gt;
&lt;li&gt;中音扬声器：通过带通滤波器，接收中频信号&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;无线通信中的匹配网络&lt;/h4&gt;
&lt;p&gt;在无线通信系统中，天线阻抗通常为50Ω或75Ω，而放大器输出阻抗可能不同。为了实现最大功率传输，需要设计匹配网络，使天线阻抗与放大器输出阻抗匹配。&lt;/p&gt;
&lt;h4&gt;电力系统中的功率因数校正&lt;/h4&gt;
&lt;p&gt;在电力系统中，感性负载（如电动机）会导致电流滞后于电压，降低功率因数。通过并联适当的电容，可以补偿相位差，提高功率因数，减少能源浪费。&lt;/p&gt;
&lt;p&gt;通过这些实际应用，我们可以看到阻抗概念不仅是一个抽象的物理概念，也是现代电子技术和电力系统的基础。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;h3&gt;笔记回顾&lt;/h3&gt;
&lt;p&gt;在这篇笔记中，我们经历了一个从简单到复杂、从具体到抽象的学习过程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;从电阻开始&lt;/strong&gt;：我们首先回顾了电阻的概念，了解了它的历史起源、物理本质和影响因素。电阻作为电路中最基本的元件，为我们理解能量耗散提供了基础。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;进入交流世界&lt;/strong&gt;：接着，我们探讨了交流电与直流电的区别，认识到交流电路中出现的特殊现象，如电磁感应、电容效应等，这些现象使得简单的欧姆定律不再适用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;认识特殊元件&lt;/strong&gt;：我们深入研究了电感和电容的物理原理，了解了它们在交流电路中的特殊行为，特别是它们与电阻的根本区别——电感和电容阻碍的是电流和电压的变化，而不是电流和电压本身。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;引入阻抗概念&lt;/strong&gt;：通过将电阻、感抗和容抗统一在一个复数框架下，我们引入了阻抗的概念。阻抗不仅包含了电路对交流电的阻碍作用，还包含了电压与电流之间的相位关系。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;数学工具的应用&lt;/strong&gt;：我们学习了如何使用复数和相量来简化交流电路的分析，将微分方程转换为代数方程，大大简化了计算过程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;实践验证理论&lt;/strong&gt;：最后，我们通过实际应用案例和实验演示，验证了阻抗理论，并了解了它在现代电子技术中的广泛应用。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;阻抗概念的深层意义&lt;/h3&gt;
&lt;p&gt;阻抗概念的重要性不仅在于它的实用性，还在于它体现了物理学中的一种重要思想：&lt;strong&gt;统一与简化&lt;/strong&gt;。通过引入复数表示，我们能够将原本复杂的交流电路问题简化为类似于直流电路的问题，这种思想在物理学的发展史上反复出现。&lt;/p&gt;
&lt;p&gt;阻抗概念也展示了物理学中&lt;strong&gt;模型构建&lt;/strong&gt;的过程。面对复杂的自然现象，我们不是直接陷入细节，而是构建适当的数学模型，抓住主要矛盾，忽略次要因素，从而获得对问题的深入理解。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt; &lt;/p&gt;
&lt;p&gt;由我和 GLM-4.5 合作整理于2025年8月7日&lt;/p&gt;
</content:encoded></item><item><title>Why Simple Harmonic Motion Can Be Modelled by a Reference Circle</title><link>https://blogs.ansonate.ink/posts/why-simple-harmonic-motion-can-be-modelled-by-a-reference-circle/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/why-simple-harmonic-motion-can-be-modelled-by-a-reference-circle/</guid><description>Why uniform circular motion projects into simple harmonic motion: the reference-circle picture.</description><pubDate>Sat, 18 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;During a physics lesson I was introduced to the concept of Simple Harmonic Motion (SHM), which can be modelled using a reference circle. At first I was puzzled: why can SHM be represented by a circle? Is it an assumption or a coincidence? To understand this in a more &quot;sensible&quot; way, I decided to derive the relationship between SHM and the reference circle mathematically.&lt;/p&gt;
&lt;h2&gt;1. What is SHM (Simple Harmonic Motion)&lt;/h2&gt;
&lt;p&gt;SHM is the motion of an object about a fixed point such that its acceleration $$a$$ is proportional to its displacement $$x$$ from the fixed point, and is directed towards the point. In other words, SHM follows the following equation:&lt;/p&gt;
&lt;p&gt;$$a = -C x$$&lt;/p&gt;
&lt;p&gt;where $$C$$ is a positive constant.&lt;/p&gt;
&lt;p&gt;How to model this motion? A reference circle can be used.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/why-simple-harmonic-motion-can-be-modelled-by-a-reference-circle/Unit_circle_angles_color.svg&quot; alt=&quot;A reference circle&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A reference circle. By &lt;a href=&quot;//commons.wikimedia.org/w/index.php?title=User:Jim.belk&amp;amp;action=edit&amp;amp;redlink=1&quot;&gt;Jim.belk&lt;/a&gt; - &lt;em&gt;Own work&lt;/em&gt;, Public Domain, &lt;a href=&quot;https://commons.wikimedia.org/w/index.php?curid=12062595&quot;&gt;Link&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;By projecting the motion of a particle moving in a circle onto a diameter, we have:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
x &amp;amp;= x_0\sin(\omega t+\theta) \
v &amp;amp;= \dfrac{\mathrm{d}{x}}{\mathrm{d}{t}}=x_0\omega \cos(\omega t+\theta) \
a &amp;amp;= \dfrac{\mathrm{d}{v}}{\mathrm{d}{t}}=-x_0\omega^2 \sin(\omega t+\theta) = -\omega^2 x
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Where $$\omega$$ is the angular speed of the reference circle, and $$\theta$$ is the initial phase angle. The radius of the circle corresponds to the amplitude of the SHM.&lt;/p&gt;
&lt;p&gt;But wait, why can we model SHM with a reference circle? In order to answer this question, we need to dive into the mathematics behind it.&lt;/p&gt;
&lt;h2&gt;2. Some Mathematical Derivation&lt;/h2&gt;
&lt;p&gt;In the following equation, we are going to ignore frictions and other external forces.&lt;/p&gt;
&lt;p&gt;Since we were introduced to simple harmonic motion from the motion of a spring. Let&apos;s start from the Hooke&apos;s Law, which states the force $$F$$ exerted by a spring is proportional to the displacement $$x$$ from its equilibrium position:&lt;/p&gt;
&lt;p&gt;$$
F=-kx
$$&lt;/p&gt;
&lt;p&gt;According to Newton&apos;s Second Law, we have:&lt;/p&gt;
&lt;p&gt;$$
F=ma
$$&lt;/p&gt;
&lt;p&gt;Additionally, due to the definition of acceleration, we have:&lt;/p&gt;
&lt;p&gt;$$
a=\dfrac{\mathrm{d}^2}{\mathrm{d}t^2}x
$$&lt;/p&gt;
&lt;p&gt;Let&apos;s combine these equations:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
ma &amp;amp;= -kx \
m\dfrac{\mathrm{d}^2x}{\mathrm{d}t^2} &amp;amp;= -kx \
\dfrac{\mathrm{d}^2x}{\mathrm{d}t^2} &amp;amp;= -\dfrac{k}{m}x \
\dfrac{\mathrm{d}^2x}{\mathrm{d}t^2} + \dfrac{k}{m}x &amp;amp;= 0
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Well, we have obtained a second-order differential equation. To solve it, we can assume a solution of the form:&lt;/p&gt;
&lt;p&gt;$$x(t) = e^{rt}$$&lt;/p&gt;
&lt;p&gt;Why we use this form? Because exponential functions $$e^x$$ have the property that their derivatives are proportional to themselves, which makes them suitable for solving differential equations. Let&apos;s substitute this into our differential equation:&lt;/p&gt;
&lt;p&gt;Now we substitue $$x(t) = e^{rt}$$ into the differential equation:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
r^2 e^{rt} + \frac{k}{m} e^{rt} &amp;amp;= 0
\end{align}
$$&lt;/p&gt;
&lt;p&gt;By factoring out $$e^{rt}$$, we can obtain the characteristic equation, since $$e^{rt} \neq 0$$:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
e^{rt}\left(r^2 + \frac{k}{m}\right) &amp;amp;= 0 \
r^2 + \frac{k}{m} &amp;amp;= 0
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Now, we can solve the characteristic equation:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
r^2 &amp;amp;= -\frac{k}{m} \
r &amp;amp;= \pm i \sqrt{\frac{k}{m}}
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Finally, since the roots of the characteristic equation are complex, the general solution to the differential equation is:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
x(t) &amp;amp;= C_1 e^{i \sqrt{\frac{k}{m}} t} + C_2 e^{-i \sqrt{\frac{k}{m}} t}
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Where $$C_1$$ and $$C_2$$ are arbitrary constants determined by initial conditions.&lt;/p&gt;
&lt;p&gt;Then, according to Euler&apos;s formula: $$e^{i\theta} = \cos \theta + i \sin \theta$$, we can rewrite the general solution as:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
x(t) &amp;amp;= C_1 \left(\cos\left(\sqrt{\frac{k}{m}} t\right) + i\sin\left(\sqrt{\frac{k}{m}} t\right)\right) \&amp;amp; \quad + C_2 \left(\cos\left(\sqrt{\frac{k}{m}} t\right) - i\sin\left(\sqrt{\frac{k}{m}} t\right)\right) \notag\
x(t) &amp;amp;= (C_1 + C_2)\cos\left(\sqrt{\frac{k}{m}} t\right) + i(C_1 - C_2)\sin\left(\sqrt{\frac{k}{m}} t\right)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Let $$A = C_1 + C_2$$ and $$B = i(C_1 - C_2)$$, so:&lt;/p&gt;
&lt;p&gt;$$
x(t) = A\cos\left(\sqrt{\frac{k}{m}} t\right) + B\sin\left(\sqrt{\frac{k}{m}} t\right)
$$&lt;/p&gt;
&lt;p&gt;By using the trigonometric identity (compound angle formula) we used in Pure Mathmatics 3:
&amp;lt;!-- add more details --&amp;gt;&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
A\cos\left(\sqrt{\frac{k}{m}} t\right) + B\sin\left(\sqrt{\frac{k}{m}} t\right) = R\sin\left(\sqrt{\frac{k}{m}} t + \theta \right)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;where:&lt;/p&gt;
&lt;p&gt;$$
R = \sqrt{A^2 + B^2} \quad \text{and} \quad \theta = \tan^{-1}\left(\frac{A}{B}\right).
$$&lt;/p&gt;
&lt;p&gt;We are very close to the final solution. Let&apos;s define $$\omega = \sqrt{\frac{k}{m}}$$ and $$x_0 = R$$, so:&lt;/p&gt;
&lt;p&gt;$$
x(t) = x_0\sin(\omega t + \theta)
$$&lt;/p&gt;
&lt;p&gt;It is obvious that this equation is the same as the equation of the projection of the reference circle.&lt;/p&gt;
&lt;p&gt;By getting the second derivative of $$x(t)$$, we can get the expression of $$a(t)$$:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
a(t) &amp;amp;= \frac{\mathrm{d^2} v}{\mathrm{d}t^2} = -x_0\omega^2\sin(\omega t + \theta)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Now, we can combine two equations to get the relationship bewteen $$a(t)$$ and $$x(t)$$:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
a(t) &amp;amp;= -\omega^2 (x_0\sin(\omega t + \theta)) \
a(t) &amp;amp;= -\omega^2 x(t)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;In this way, we have proved that the acceleration of the mass is proportional to its displacement from the equilibrium position and is always directed towards the equilibrium position. This is the defining characteristic of simple harmonic motion.&lt;/p&gt;
&lt;p&gt;$$
\text{Q.E.D.}
$$&lt;/p&gt;
</content:encoded></item><item><title>Protecting Secrets: Credential Handling for Open-Source Projects</title><link>https://blogs.ansonate.ink/posts/development-credential-handling-solution/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/development-credential-handling-solution/</guid><description>Keeping secrets out of open-source repos: a credential-handling workflow for public projects.</description><pubDate>Sat, 27 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;This blog aims to document an effective method I recently developed for managing credential API keys in the open-source project &lt;a href=&quot;https://github.com/Anson2251/trackmaker&quot;&gt;Trackmaker&lt;/a&gt; which I have been developing for a long time, and the journey I came up with this method. The method uses a private credential configuration file to store the credentials or passes the credentials via environment variables. The first approach simplifies debugging, while the second approach enables GitHub Actions to automatically build GitHub Pages without the need to upload a private credential configuration file.&lt;/p&gt;
&lt;h2&gt;How I Came Up with This Idea and Made a Framework&lt;/h2&gt;
&lt;p&gt;The consideration for protecting credentials dates back to when I signed up for the &lt;a href=&quot;https://www.bingmapsportal.com/&quot;&gt;Bing Maps Dev Centre&lt;/a&gt; and obtained a basic key. According to the terms of use, this credential key must not be exposed to the public. This posed a challenging task: ensuring the program can read the key without including any key-related information in the publicly accessible code on GitHub.&lt;/p&gt;
&lt;p&gt;To address this problem, I explored various solutions over several months. To evaluate the quality of these solutions, I considered the following factors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can avoid the credentials from being exposed via the source code.&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;The source code can be accessed by the public.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can avoid the credentials from being exposed via the built product.&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;The credentials may be obtained from reverse engineering.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can support the source code to be built without uploading the credentials.&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;The project should pass compilation even if the credentials are not included, making it fully open to the public.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;New credentials can be added easily.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;.gitignore&lt;/h3&gt;
&lt;p&gt;The preliminary method I came up with was straightforward: separate the storage of the credential keys and add the file containing the keys to &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[ ] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[ ] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[ ] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[ ] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Encryption of Credentials&lt;/h3&gt;
&lt;p&gt;To prevent credentials from being obtained through reverse engineering, I applied a private encryption method. I designed an algorithm to achieve this.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[ ] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[ ] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[ ] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Configuration File, Environment Variables, &amp;amp; Vite&lt;/h3&gt;
&lt;p&gt;As I started configuring GitHub Pages to preview the built page, the need to build with the public source code emerged. Credentials can be passed using &lt;code&gt;secrets&lt;/code&gt;, where the credentials can be stored and accessed when building the page. I also found a useful configuration in Vite, a bundler I used, called &lt;a href=&quot;https://vitejs.dev/config/shared-options.html#define&quot;&gt;&lt;code&gt;define&lt;/code&gt;&lt;/a&gt;, which defines global constant replacements. With this option, I can replace constants with credentials during the build. To facilitate debugging, I modified the &lt;code&gt;vite.config.ts&lt;/code&gt; to read credentials from a file and environment variables for GitHub Pages.&lt;/p&gt;
&lt;p&gt;Additionally, using &lt;code&gt;JavaScript Obfuscator&lt;/code&gt; can encrypt the credentials, ensuring they do not exist in plaintext form in the production build.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[x] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[x] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[ ] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Generalised Framework&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;There is a node package called &lt;code&gt;dotenv&lt;/code&gt;, which does exactly what the following framework does.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;To simplify adding new credentials, I packaged the key replacement into a more generalised framework.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[x] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[x] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[x] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Final Code for the framework&lt;/h2&gt;
&lt;p&gt;The modified &lt;code&gt;vite.config.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { promises as fs } from &quot;fs&quot;; // handling file operations in node
import { defineConfig } from &quot;vite&quot;;

// Default path for the credential configuration file
const credentialFileDefaultPath = &quot;./credentials-config.json&quot;;

// Type definition for credential items
type CredentialItemType = {
  name: string;
  type: &quot;string&quot; | &quot;number&quot;;
};

// List of credential items to be managed
const credentialItems: CredentialItemType[] = [
  {
    name: &quot;EXAMPLE_KEY&quot;,
    type: &quot;string&quot;,
  },
];

/**
 * Check if a file exists at the given file path.
 * @param filePath - Path to the file.
 * @returns Promise&amp;lt;boolean&amp;gt; - True if the file exists, false otherwise.
 */
async function checkFileExist(filePath: string): Promise&amp;lt;boolean&amp;gt; {
  try {
    await fs.access(filePath);
    return Promise.resolve(true);
  } catch (error) {
    return Promise.resolve(false);
  }
}

/**
 * Read the content of a file.
 * @param filePath - Path to the file.
 * @returns Promise&amp;lt;string&amp;gt; - Content of the file.
 */
async function readFile(filePath: string): Promise&amp;lt;string&amp;gt; {
  try {
    const config = await fs.readFile(filePath, {
      encoding: &quot;utf-8&quot;,
    });
    return Promise.resolve(config);
  } catch (err) {
    return Promise.reject(`Cannot read from the file &quot;${filePath}&quot;`);
  }
}

/**
 * Load credentials from a specified file path or environment variables.
 * @param credentialFilePath - Path to the credential configuration file.
 * @returns Promise&amp;lt;Record&amp;lt;string, string&amp;gt;&amp;gt; - An object containing the final credentials.
 */
async function getCredentials(credentialFilePath: string) {
  // Log the use of a custom credential configuration file
  if (credentialFilePath !== credentialFileDefaultPath) {
    console.log(`Using credential configuration file: ${credentialFilePath}`);
  }

  // Check if the credential file exists
  const credentialFileExist = await checkFileExist(credentialFilePath);
  // Load credential file content if it exists, otherwise use an empty object
  const credentialFileContent: Record&amp;lt;string, string | number&amp;gt; =
    credentialFileExist ? JSON.parse(await readFile(credentialFilePath)) : {};
  // Object to hold the final credentials
  const finalCredential: Record&amp;lt;string, string&amp;gt; = {};

  // Iterate over each credential item
  credentialItems.forEach((item) =&amp;gt; {
    // Try to get the value from environment variables or credential file content
    let value: string | number | undefined =
      process.env[item.name] || credentialFileContent[item.name] || undefined;

    // If value is undefined, issue a warning and set it to an empty string
    if (typeof value === &quot;undefined&quot;) {
      console.warn(
        &quot;\x1b[33m%s\x1b[0m&quot;,
        `Credential item &quot;${item.name}&quot; cannot be found in the environment or the &quot;${credentialFilePath}&quot;`
      );
      value = &quot;&quot;;
    }

    // Convert the value to the appropriate type
    if (item.type === &quot;string&quot;) value = String(value);
    else if (item.type === &quot;number&quot;) value = Number(value);

    // Add the formatted credential to the finalCredential object
    finalCredential[`__${item.name}__`] = JSON.stringify(value);
  });

  return finalCredential;
}

// Export the Vite configuration
export default defineConfig(async () =&amp;gt; {
  // Get the path for the credential configuration file, defaulting to the specified default path
  const credentialsConfigPath =
    process.env.CREDENTIALS_CONFIG_PATH || credentialFileDefaultPath;

  return {
    // Define global constants with the loaded credentials
    define: await getCredentials(credentialsConfigPath),
  };
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;This framework simplifies secure credential management for open-source projects by using private configuration files and environment variables. It ensures credentials are protected while supporting automated builds with GitHub Actions. This approach helps maintain security without complicating the development process.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Anson2251/trackmaker/blob/main/README.md&quot;&gt;Trackmaker Project README.md&lt;/a&gt;, accessed July 27, 2024.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vitejs.dev/config/shared-options.html#define&quot;&gt;Vite Configuration &lt;code&gt;define&lt;/code&gt;&lt;/a&gt;, accessed July 27, 2024.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>New Blog Site!</title><link>https://blogs.ansonate.ink/posts/new-blog-site/</link><guid isPermaLink="true">https://blogs.ansonate.ink/posts/new-blog-site/</guid><description>Announcing the new blog and the plan to turn old project pages into posts.</description><pubDate>Mon, 24 Jun 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;I have updated my personal site to replace the previous plain Markdown-based site. The new site is built on Jekyll, allowing for better presentation and easier organization of my content.&lt;/p&gt;
&lt;h2&gt;Migration of Content&lt;/h2&gt;
&lt;p&gt;I will be transferring the content from the &lt;code&gt;Useful Links&lt;/code&gt; section of the old site into individual blog posts that explain how the projects work. This process will take some time, but I will complete it gradually.&lt;/p&gt;
&lt;h2&gt;Useful Links from the Previous Page&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The repository for this site: &lt;a href=&quot;https://github.com/Anson2251/Anson2251.github.io&quot;&gt;这个主页的储存库&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Sequence Calculator: &lt;a href=&quot;https://anson2251.github.io/sequence/&quot;&gt;数列计算器&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Binomial Expansion Calculator: &lt;a href=&quot;https://anson2251.github.io/binomial-expansion-calculator/&quot;&gt;展开 (a+b)^n&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Solve2048 Demo: &lt;a href=&quot;https://anson2251.github.io/solve2048/&quot;&gt;solve2048 demo&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Jekyll?&lt;/h2&gt;
&lt;p&gt;Jekyll is a convenient tool for transforming plain text, written in Markdown, into static websites and blogs, which suits my preferences perfectly. GitHub also supports Jekyll with workflows for building and hosting the site online, allowing for a smooth transition.&lt;/p&gt;
&lt;h2&gt;Future Posts&lt;/h2&gt;
&lt;p&gt;In upcoming posts, I will explain the design ideas behind the projects listed in the &lt;code&gt;Useful Links&lt;/code&gt; section. I will also refurbish the projects and update the README files for each project.&lt;/p&gt;
</content:encoded></item></channel></rss>