拆解一个 7.6k star 的紫微排盘引擎:36KB 算法如何跑在浏览器里

本站新上线了一个紫微斗数排盘页:kunyun-tech.com/ziwei/。

这篇文章不聊命理,聊工程:一个原本跑在 Next.js 服务端的排盘引擎,怎么被拆出来塞进浏览器,中间踩了哪些坑,以及一次值得记住的缓存事故。


一、起点:一个 MIT 协议的引擎

上游项目 Renhuai123/ziwei-doushu 的现状:

1
2
3
4
5
Stars        7,588
Forks 1,549
License MIT
Created 2026-05-01
最近提交 2026-10-24

项目定位是「紫微斗数开源排盘引擎」,含完整排盘算法、四化系统、格局知识库、古籍原文数据。

关键事实:它是 Next.js 应用,不是静态库。

1
2
3
4
5
6
"next": "^16.3.8",
"iztro": "^2.5.8", 排盘算法核心
"lunar-javascript": "^1.7.3", 农历转换
"pg": "^8.20.0", ← PostgreSQL
"ioredis": "^5.10.1", ← Redis
"@anthropic-ai/sdk": "^0.27.0" ← Claude API

看到 pg 和 ioredis 就该明白了:直接部署到静态站是不可能的。

但作者做了一件很关键的事——他把排盘 Demo 单独迁成了客户端组件:

1
2
3
// app/chart/page.tsx 第一行
'use client';
import { generateChart } from '@/lib/ziwei/algorithm';

注释写得更直白:

这是一个最小可运行示例:用本仓库的排盘引擎 generateChart() 配合基础 UI 组件……线上商业版的完整交互界面不在开源范围内;但排盘内核——安星算法、四化、格局识别、古籍库——完全开放

「内核开放,界面不开」——这个切分决定了后面所有事情。


二、动手术:11 个文件里挑出能进浏览器的

lib/ziwei/ 目录下的文件清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
algorithm.ts              36,104 B   ← 排盘主流程
patterns.ts 54,637 B ← 格局识别(最大的一个)
school-data.ts 21,868 B ← 星曜亮度流派数据
school-config.ts 15,854 B
school-tables.generated.ts 15,781 B ← 生成代码
sihua.ts 10,203 B ← 四化
shier-shen.ts 8,171 B ← 十二神
true-solar-time.ts 5,814 B
constants.ts 5,638 B
yunxian-context.ts 5,430 B
types.ts 4,449 B
────────────────────────────────
合计 183,949 B / 3,536 行

依赖排查:只有两个外部依赖

1
2
3
4
5
$ grep -h "^import" *.ts | grep -v "from '\./" | sort | uniq -c
1 import { astro } from 'iztro';
1 import { Solar, LunarMonth } from 'lunar-javascript';
1 import { Lunar } from 'lunar-javascript';
1 import 'server-only'; ← 就是它

iztro 是纯计算库,lunar-javascript 是纯农历换算。没有一个需要 Node API。

卡点:一行 server-only

patterns.ts 第 18 行:

1
2
import 'server-only';
import type { ZiweiChart, Palace, Star } from './types';

server-only 是 Next.js 的一个特殊包,作用是编译期断言:如果这个模块被打进客户端 bundle,构建直接失败。

它的用途是防止服务端密钥泄漏到浏览器。但 patterns.ts 里根本没有密钥——它只是做格局识别的纯函数:

1
2
3
4
5
6
export interface PatternCondition {
required: string[]; // 必须满足条件
bonus?: string[]; // 加分条件
breaking?: string[]; // 破格条件
}
export function detectPatterns(chart: ZiweiChart): Pattern[]

这是作者的一种保守标记:只要是服务端发现的逻辑就先加上 server-only,宁可错杀。

去掉这一行就能进浏览器。这是整个改造里唯一必须改的源码。


三、打包:932 KB 里到底装了什么

用 esbuild 打成 IIFE:

1
2
3
npx esbuild entry.ts \
--bundle --format=iife --target=es2020 --minify \
--outfile=ziwei-engine.js

结果 932,415 B(gzip 后 270,424 B)。

体积大头在哪?用 esbuild 的 --metafile 拆开看:

模块 贡献字节 占比
lunar-javascript/lunar.js 330,781 35.5%
lunar-typescript/dist/index.cjs 327,266 35.1%
i18next 48,143 5.2%
patterns.ts(自己的) 45,165 4.8%
plain.ts(我加的解读层) 15,280 1.6%
school-tables.generated.ts 12,980 1.4%
iztro/lib/star/location.js 12,539 1.3%
algorithm.ts 10,071 1.1%
其余 iztro + dayjs ~33,000 3.5%

一个意外发现:两份农历库

前两名加起来 658 KB,占 70.6%,而且是两个不同的农历实现:

  • lunar-javascript 1.7.7
  • lunar-typescript(被 iztro 间接依赖)

也就是说,同一个农历换算逻辑在包里存了两遍。iztro 依赖 lunar-typescript,业务代码直接依赖 lunar-javascript,打包器不会合并。

这是典型的依赖冗余。真要压体积,第一步就是统一到一份。但功能优先,我没动——改这里要重新验证全部节气和闰月逻辑,风险大于收益。

我自己的代码只占 6.4%

patterns.ts + plain.ts + algorithm.ts + school-tables = 83,496 B,约 9% 的未压缩体积。

换句话说:这个 932 KB 的包里,91% 是别人写的代码。 这是使用开源引擎的必然代价,也是重要认知——体积优化空间全在依赖层。


四、三个必须处理的运行时问题

4.1 全局副作用:astro.config 在模块加载时就执行

algorithm.ts 顶部(第 91 行):

1
2
3
4
5
// 模块加载即写入默认亮度表 + 农历正月初一换年规则(astro.config 为全局副作用)
astro.config({
yearDivide: 'normal',
brightness: DEFAULT_BRIGHTNESS,
});

作者自己加了警告注释:

1
2
// 注意:astro.config 是全局副作用,每次调用影响所有后续 generateChart
// 服务端多用户场景下要小心 race condition(chart 生成是同步的,影响小)

在服务端这是隐患;在浏览器里反而安全——单用户、单线程,全局状态只影响自己这一页。

这算一个「架构缺陷恰好在目标环境无害」的例子。但它意味着:引擎不能多次初始化,也不能并发调用。

4.2 日期校验必须自己做

1
2
3
4
5
6
// 公历日期必须真实存在:2 月 30 日、非闰年 2 月 29 日、4 月 31 日这类日期,
// iztro 不报错而是顺延成下个月的头几天排盘,结果会和真实日期的盘重复。
const probe = new Date(Date.UTC(year, month - 1, day));
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
throw new RangeError(`solar date does not exist: ${year}-${month}-${day}`);
}

这个防御很必要:用户输入 2 月 30 日,iztro 会静默按 3 月 2 日排盘,给出一个看起来正常但完全错的盘。静默错误比报错危险得多。

作者用「构造 Date 再比对」的方式做校验,这是 JS 里检测非法日期的标准手法。

4.3 流派开关是全局变量

1
2
3
4
5
let currentBrightSchool: BrightnessSchool = 'QS';

export function setBrightnessSchool(school: SchoolBrightnessKind): void {
currentBrightSchool = school === 'default' ? 'QS' : school;
}

四派亮度表:'QS' | 'XD1' | 'ZZ' | 'XD2'(斗数全书 / 现代修订一 / 中州派 / 现代修订二)。

代码注释里记录了一场持续几个月的口径争论:

1
2
3
4
★ 2026-10-20 default = 《斗数全书》QS(站长定:主流排盘默认口径是什么,我们默认就是什么)。
证据:主流排盘软件设置页「重置设置 → 重置为系统默认值」后,星曜亮度停在
「依据斗数全书 (默认)」;会员发来的金标准盘 53 颗星亮度与 QS 表 53/53 一致、
与 XD2 只 44/53。

把「为什么选这个默认值」和证据写进代码注释——这是我在这份代码里最欣赏的地方。


五、性能:一次排盘 21 毫秒

在 Node 里预热后测 300 次:

1
2
3
generateChart      21.29 ms    ← 几乎全部耗时在这里
detectPatterns 0.03 ms ← 可以忽略
explainPalace 微量

排盘本身是 21 ms 量级,格局识别是微秒量级。

这个分布很说明问题:generateChart 里调用了 astro.bySolar(),也就是把公历转农历 + 安星 + 排十二宫的完整流程;而格局识别只是在已算好的盘上做模式匹配。

对交互来说 21 ms 完全可以接受(一帧 16.7 ms,用户点按钮后感知不到延迟)。这也解释了为什么不需要 Web Worker、不需要预计算、不需要缓存盘面。

如果哪天要做到「滑一下大限就重排」,那就得考虑了。


六、一次真实的缓存事故

这一段是整篇文章最该读的部分,因为它跟紫微无关,跟所有静态站 JS 更新有关。

症状

页面改完后,用户打开说:「点宫位,还是都是显示暂无解读」。

我这边测试全部正常。反复验证:

1
2
3
线上 HTML     md5 与本地一致
线上引擎 explainPalace 函数存在,调用返回 208 字
无头浏览器 点 12 个宫位全部正常

我测的一切都对,用户看到的就是错的。

根因

nginx 配置里有这么一条:

1
2
3
4
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 7d;
add_header Cache-Control "public, no-transform";
}

所有 JS 缓存 7 天。

时间线还原:

1
2
3
4
5
09:03  用户第一次打开 → 浏览器缓存旧版 ziwei-engine.js(那时还没有 explainPalace)
09:04 我更新了 JS,但文件名没变
→ 浏览器按 7 天规则继续用旧缓存
之后 页面是新版(有解读框)+ 引擎是旧版(没有解读函数)
→ 点任何宫位都显示「暂无解读」

读代码就明白,这四个字只在一个分支出现:

1
2
var ex = E.explainPalace ? E.explainPalace(p) : null;
var body = ex ? '<p>' + esc(ex.means) + '</p>' : '<p class="empty">暂无解读</p>';

explainPalace 不存在 → ex 为 null → 显示「暂无解读」。逻辑完全正确,错的是文件版本不一致。

为什么我一直测不出来

因为我每次都用全新的浏览器上下文:

1
const ctx = await b.newContext({...});   // 无缓存

无头测试天然免疫缓存问题。这暴露了测试策略的盲区:「在我这儿是好的」在缓存场景下毫无意义。

修复

把文件名换掉,绕过缓存规则:

1
ziwei-engine.js  →  ziwei-engine.v2.js?v=2

旧文件留在服务器上没删(方便回退),新文件走全新 URL,缓存里没有它。

正确做法

静态站更新 JS 有两条路:

方案 做法 优点 缺点
内容哈希 ziwei-engine.a3f2c1.js,每次构建改名 永久缓存 + 永不失效 需要构建工具支持
版本参数 ziwei-engine.js?v=2 简单,手动可控 需要人工记得改
不缓存 HTML location = /index.html { add_header Cache-Control "no-cache"; } 改页面立即生效 JS 仍需版本控制

最优组合是「HTML 不缓存 + JS 内容哈希」:HTML 每次都回源校验,里面引用的 JS 文件名带哈希,内容变了文件名就变,天然失效。

我这次只做了版本参数(最小改动,也够用),因为你说过 nginx 改动要慎重。

教训

改了 JS 却不改文件名,等于没改。

这句话对任何 CDN、任何静态站、任何 expires 7d 都成立。


七、我加的解读层:纯数据表,零依赖

原引擎输出的是结构化数据:

1
2
3
4
5
6
7
8
9
{
"name": "命宫",
"stars": [
{ "name": "武曲", "type": "major", "brightnessRaw": "旺", "siHua": "" },
{ "name": "天府", "type": "major", "brightnessRaw": "庙", "siHua": "" }
],
"isMingGong": true,
"daXianAge": [4, 13]
}

专业但不好懂。我加了一层 plain.ts,230 行,全是静态映射表:

1
2
3
4
5
STAR_PLAIN     14 条   十四主星的白话释义
PALACE_PLAIN 12 条 十二宫各代表什么领域
PATTERN_PLAIN 5 条 常见格局的一句话翻译
SIHUA_PLAIN 4 条 禄权科忌的倾向性描述
BRIGHTNESS_PLAIN 7 条 庙旺得利平不陷的能量含义

设计原则三条:

  1. 先说代表什么,再说依据,最后给一句能自己回答的问题
  2. 不做吉凶断言,不预测具体事件
  3. 四化里的「忌」不写成凶——传统上忌被理解成凶,但实际读数更像「你在这儿用力最多」

第 3 条是有意的。让内容易懂的同时,不该把它说得更吓人。

这一层没有任何计算,只是查表和字符串拼接。好处是:改文案不用碰引擎,改引擎不用担心文案。


八、最终形态

1
2
3
4
页面        22,471 B   (gzip  8,437 B)
引擎 932,415 B (gzip 270,424 B)
────────────────────────────────
首屏传输 约 279 KB

架构上是三明治:

1
2
3
index.html          表单 + 4×4 命盘渲染 + 交互(原生 JS,零框架)
ziwei-engine.v2.js 排盘内核 + 我的解读层(IIFE 单文件)
↑ 只有访问 /ziwei/ 才加载,不影响博客其他页面

没有用框架:Vue/React 会再加 100 KB+ 运行时,而这个页面只有「填表 → 排盘 → 点宫位」三个交互,原生 JS 两百行就够。

没有拆分加载:900 KB 一次下完,gzip 后 270 KB,首屏约 1 秒。拆成「排盘 240 KB + 格局 680 KB 异步」能快一点,但多一次请求,还要处理加载态。在这个使用频率下不值得。


九、关于 MIT 协议和署名

引擎是 MIT 协议,意味着可以商用、可以修改、可以闭源分发,唯一义务是保留版权声明和许可声明。

我在页脚写了三段:

本页的归属说明

排盘内核(安星算法、四化系统、格局识别、古籍数据)来自开源项目 Renhuai123/ziwei-doushu,遵循 MIT 协议。

本站在其基础上自行编写了页面渲染层与通俗解读层(十四主星白话释义、十二宫领域说明、术语表),未修改排盘算法本身。感谢原作者的开放工作。

本站为个人学习作品,不提供任何形式的付费服务,内容仅供传统文化参考。

三句话各有作用:

  • 第一句:法律要求的署名
  • 第二句:划清责任边界——排盘结果由原作者决定,我改的只是渲染和解读
  • 第三句:说明性质——不是商业服务,避免误解

「未修改排盘算法本身」这句尤其重要。MIT 允许修改,但如果排盘算错了,责任不能默认算到原作者头上。写清楚谁做了什么,对双方都公平。


十、如果继续做,我会先做什么

按投入产出排序,前两条是真问题,后面都是锦上添花:

  1. 统一农历依赖:lunar-javascript 和 lunar-typescript 二选一,能省 300 KB 左右。但要重新验证全部节气和闰月逻辑,属于高风险优化。
  2. **HTML 加 Cache-Control: no-cache**:彻底解决这次的缓存问题,不用再靠 ?v=N 手动绕。
  3. 单星点击解释:explainStar 函数已写好,界面还没接。
  4. 大限 / 流年切换:chart.daXians 数据现成,缺的是交互。
  5. 分享卡片生成:用 Canvas 把命盘画出来导出 PNG。

第 1 条不做是因为收益(300 KB)小于风险(排错盘)。这个判断标准适用于所有「优化」:先问会不会算错,再问快了多少。


结语

这个项目从「能不能跑」到「上线能看到」,实际工作量集中在三件事:

  • 剥离:识别哪些代码能进浏览器(server-only 一行)
  • 验证:无头浏览器跑通排盘、逐宫点击、移动端布局
  • 缓存:一次真实事故,换来一条铁律

紫微斗数本身是玄学,但把别人的玄学引擎搬进浏览器是一件很普通的工程活:读依赖、量体积、找卡点、验结果。这篇记录的是后者。

访问地址:kunyun-tech.com/ziwei/

引擎来源:Renhuai123/ziwei-doushu(MIT)