
做微信小程序的时候碰上一个特别常见的需求页面要显示阴历。比如打卡类小程序除了公历日期还得在下方带一行“农历三月初三”日历类小程序要标注除夕、春节、端午再或者门店预约场景里顾客选择日期时希望看到节气信息。微信小程序获取阴历很多人第一反应是去找现成接口结果要么需要AppKey要么返回字段和你要的对不上要么请求量一大就要收费。其实这件事完全可以用一份本地JS算法解决把网络依赖降到最低包体积也不大。这篇文章把我实际做过的方案完整拆一遍从原理到代码再到踩坑一次性说透。这里先声明一下我们日常口语里说的阴历和严谨意义上的农历是有细微差异的农历是阴阳合历而实际业务开发里基本把两者当同一件事处理所以下文不严格区分统一用“阴历/农历”混称。1. 微信小程序获取阴历的整体思路与选型解析1.1 不要指望系统API直接给你农历前端开发者最容易踩的第一个坑是以为小程序基础库或者Date对象能直接拿到农历字段。实际上不管是浏览器环境、Node环境还是小程序环境标准的Date API只支持公历时间戳、年月日、星期没有任何一个原生方法输出农历信息。换句话说“今天是什么农历日期”这个问题的答案不在任何系统API里需要自己算。算农历的核心不是时间戳而是“历法映射”。天干地支、生肖、闰月、大小月、节气这些信息不会因为时间戳变化就自动生成必须依赖一份“农历数据表”或者“天文算法库”来推算。理解了这个边界后面的方案选择就容易多了。1.2 三条路线对比本地算法、云函数、第三方接口微信小程序获取阴历常见路线有三条。我把它们放在一起对比直接说结论。方案优点缺点适用场景本地JS算法无网络依赖、无请求费用、响应极快数据表有年份上限、算法需要测试大部分前端日历、打卡、预约场景云函数转发可按需扩展接口、数据可更新需要云开发环境、有冷启动延迟、可能产生调用费用需要后端业务联动、数据需要频繁修正第三方HTTP接口无需自己维护数据有鉴权、限流、商用可能收费、返回结构不可控临时Demo、低频调用、后端已有聚合接口我的建议是以本地JS算法为主云函数作为兜底。简单业务直接把农历工具类打进小程序包里代码量其实很小压缩后一般只有几KB到十几KB对包体积几乎没有影响。为什么不推荐先上第三方接口因为农历数据属于低频变化、高频读取的数据接口每次都要走公网请求用户在弱网环境下容易看到空白或加载失败。本地计算是确定性的不受网络波动影响体验完全可控。2. 核心算法与数据准备农历转换原理2.1 农历不是“一个月多少天”这么简单很多人对农历的理解是“月大三十天月小二十九天”实际上这是农历历法里最基础的一部分真正复杂的是“闰月”。公历一年约365.24天农历一年约354天两者每年相差约11天。为了不让春节跑偏到夏天历法上每隔两三年就要插入一个闰月闰月这一年就有13个月。比如常见的闰四月、闰六月都是这么来的。所以农历算法里有一个必须解决的问题某一年有没有闰月闰月是哪个月闰月是大月还是小月这些信息不会按照简单周期规律出现只能靠数据表记录。2.2 一份压缩的农历数据表怎么解读现在公开流传的农历转换逻辑大多基于一份从1900年到2100年共201年的数据表。它看起来是一串十六进制数字比如const LUNAR_INFO [ 0x04bd8, 0x04ae0, 0x0a570, // 这里共201项对应19002100年 ];每一项代表一个农历年的完整“历法配置”。按二进制位拆开来看常见定义是从低到高的4位表示该年的闰月月份0表示没有闰月剩余位表示每月是大月还是小月通常用16位二进制位来表示一年的12个月也可以用“当年每个月是否30天”的方式编码具体映射以算法库为准。这种压缩方式好处明显一年只需一个数字201年就是201个数字体积极小。换算成字符串或字符数组放进小程序代码里非常轻量。也许有人会问为什么不用一个完整的公历到农历的对照表每天一条记录那不是更直观201年大概7万天全量罗列后体积会大很多而且查表效率也远不如“先算偏移、再按月份循环”的算法。压缩数据表是最经典的平衡方案。2.3 公历转农历的核心算法流程抛开具体库的实现差异公历转农历的通用思路分四步确定基准日。大多数公开算法以公历1900年1月31日为基准对应农历1900年正月初一。计算输入公历日期与基准日之间的偏移天数。从基准农历年开始逐年迭代每次减去当年的农历总天数。如果偏移天数小于当年农历总天数则定位到该农历年。在当前农历年内按月份迭代依次判断当年是否有闰月、每个月是大月还是小月逐步减去月份天数直到定位到具体农历月日。这个过程看起来简单但有小细节必须小心偏移天数要考虑时区、要考虑基准日当天到底算0还是算1否则很容易出现“差一天”的问题。这个坑我放到后面单独讲。3. 小程序端一次性实现复现一个农历工具类3.1 目录结构与基础准备小程序项目里我习惯把这类纯逻辑工具放在utils目录下新建一个lunar.js和业务页面解耦。比如miniprogram/ utils/ lunar.js pages/ index/ index.js index.wxmllunar.js里不依赖任何wx.* API也不依赖ES6之外的高级特性这样后续可以在任何小程序平台、甚至普通H5里复用。需要注意小程序开发里有两个运行环境逻辑层运行在JavaScriptCore或V8里视图层运行在WXML的渲染环境。工具类如果要在逻辑层用可以直接require如果想在WXML里直接用函数返回值则要考虑WXS的兼容性。我后面会专门说WXS的事。3.2 核心转换函数实现下面给出一段经过简化的核心代码示例保留主逻辑数据表部分以注释形式展示。实际项目中可以直接引入完整数据表。// utils/lunar.js const LUNAR_INFO [ // 省略19002100年的压缩历法数据 // 0x04bd8, 0x04ae0, 0x0a570, ... ]; function getLeapMonth(year) { // 取低4位得到闰月月份0表示无闰月 return LUNAR_INFO[year - 1900] 0xf; } function getMonthDays(year, month) { // 判断指定月是大月(30天)还是小月(29天) // 具体位运算方式取决于数据表编码规则 return (LUNAR_INFO[year - 1900] (0x10000 month)) ? 30 : 29; } function getLunarYearDays(year) { let sum 0; for (let i 1; i 12; i) { sum getMonthDays(year, i); } const leapMonth getLeapMonth(year); if (leapMonth 0) { sum getMonthDays(year, leapMonth); } return sum; }然后是对外暴露的转换方法const BASE_YEAR 1900; const BASE_DATE new Date(1900, 0, 31); // 1900-01-31 const BASE_LUNAR_YEAR 1900; const BASE_LUNAR_MONTH 1; const BASE_LUNAR_DAY 1; function solarToLunar(year, month, day) { // 这里传入的month建议符合真实月份比如1月就传1 const offset Math.floor( (new Date(year, month - 1, day) - BASE_DATE) / 86400000 ); let lunarYear BASE_LUNAR_YEAR; let lunarMonth BASE_LUNAR_MONTH; let lunarDay BASE_LUNAR_DAY; let daysLeft offset; while (daysLeft 0) { const yearDays getLunarYearDays(lunarYear); if (daysLeft yearDays) { daysLeft - yearDays; lunarYear; } else { break; } } let leapMonth getLeapMonth(lunarYear); let isLeapMonth false; for (let i 1; i 12; i) { const monthDays getMonthDays(lunarYear, i); if (daysLeft monthDays) { lunarMonth i; lunarDay daysLeft 1; break; } daysLeft - monthDays; // 如果当前月是闰月需要先把闰月天数也处理掉 if (leapMonth 0 i leapMonth) { const leapDays getMonthDays(lunarYear, i); if (daysLeft leapDays) { lunarMonth i; lunarDay daysLeft 1; isLeapMonth true; break; } daysLeft - leapDays; } } return { lunarYear, lunarMonth, lunarDay, isLeapMonth, }; } module.exports { solarToLunar, getLeapMonth, getMonthDays, };这段逻辑我没有逐字节对齐某一份数据表的编码真正落地时建议直接用经过验证的完整数据文件和配套位运算函数。但流程是通用的先算偏移再逐年减再逐月减。这里有一个典型容易写错的地方判断闰月的位置。闰月不是放在所有月份之后而是固定在某个月份后面。比如闰四月就是四月过完之后再过一遍“闰四月”然后才到五月。所以循环到第4个月的时候需要先把正常四月的天数减掉再判断有没有闰四月如果有且偏移天数小于闰四月天数就定位到闰四月。3.3 页面引用与显示工具类写完页面里使用起来就简单了。比如在index.js中const lunar require(../../utils/lunar.js); Page({ data: { lunarText: }, onLoad() { const now new Date(); const year now.getFullYear(); const month now.getMonth() 1; const day now.getDate(); const result lunar.solarToLunar(year, month, day); const lunarText formatLunar(result); this.setData({ lunarText }); } }); function formatLunar(result) { // 这里做中文转换正月、腊月、初一、十五等 const monthCn [正, 二, 三, 四, 五, 六, 七, 八, 九, 十, 冬, 腊]; const dayCn [ 初一,初二,初三,初四,初五,初六,初七,初八,初九,初十, 十一,十二,十三,十四,十五,十六,十七,十八,十九,二十, 廿一,廿二,廿三,廿四,廿五,廿六,廿七,廿八,廿九,三十 ]; let monthStr (result.isLeapMonth ? 闰 : ) monthCn[result.lunarMonth - 1] 月; let dayStr dayCn[result.lunarDay - 1]; return monthStr dayStr; }然后在WXML里直接显示view classdate-card view classsolar-date{{solarText}}/view view classlunar-date{{lunarText}}/view /view这是最简单的用法。实际项目里往往还要显示生肖、天干地支、节气甚至宜忌可以在工具类里继续扩展但核心的“公历转农历”就到这里。3.4 常见节日与节气的补充处理只拿到农历月日还不够业务里经常要显示“除夕”“春节”“端午”“中秋”。这些节日本质上是“特定农历月份日期的别名”比如除夕是腊月三十或腊月二十九中秋节是八月十五。实现方式是在拿到农历月日后做一次映射。function getLunarFestival(lunarMonth, lunarDay) { if (lunarMonth 1 lunarDay 1) return 春节; if (lunarMonth 1 lunarDay 15) return 元宵节; if (lunarMonth 5 lunarDay 5) return 端午节; if (lunarMonth 7 lunarDay 7) return 七夕; if (lunarMonth 8 lunarDay 15) return 中秋节; if (lunarMonth 9 lunarDay 9) return 重阳节; return ; }这里有个细节除夕不是固定腊月三十。农历腊月可能只有二十九天那么除夕就是腊月二十九。判断“是否除夕”时不要硬编码日期应该先判断当月是腊月再看它是否是最后一天。但多数情况下业务只需要在腊月三十或腊月二十九那一天给一个提示可以直接判断“是否腊月且该日为该月最后一天”来实现。节气部分更复杂因为节气是按太阳黄经计算的不能简单用农历日期映射。常见做法是准备一张从1900年到2100年的节气数据表或者用近似公式计算。如果只是为了显示当天的节气可以把节气数据表也做成压缩数组和农历数据表一起维护。这部分涉及内容较多小程序里一般只在日历详情页使用。3.5 WXML里不能直接调用工具类很多新手写了工具类之后想在WXML里直接调用lunar.solarToLunar发现报“undefined is not a function”。原因是WXML模板里只能绑定数据不能直接执行逻辑层函数更不能调用require进来的CommonJS模块。所以必须把转换结果提前计算好塞进data再渲染。如果需要在滚动日历中展示每天的农历信息不要一次性计算几百条然后放进setData那样容易超过小程序单次数据量限制。更推荐的做法是分页渲染每次只计算当前屏幕可见月份的农历数据滚动到下一个月份再增量计算。4. 常见问题与排查技巧实录4.1 显示的农历日期总是差一天这是最经典的坑。原因往往是偏移天数的计算方式不对。new Date(年份, 月份, 日期)在JavaScript里是本地时间如果用户在北京时间的UTC8时区一般没问题。但有些手机会把系统时区设为UTC或其他时区这时候直接用两个Date做差再除86400000可能得到整数偏差。更稳妥的做法是不要依赖“当前时刻”计算而是把年月日拆开用Date.UTC来构造时间戳const BASE_UTC Date.UTC(1900, 0, 31); const targetUTC Date.UTC(year, month - 1, day); const offset Math.floor((targetUTC - BASE_UTC) / 86400000);用Date.UTC的好处是避开本地时区干扰。只要用户选择的“公历日期”是明确的年月日就不应该和当前系统时区有任何关系。这个改动很小但能把“差一天”的问题彻底解决。4.2 iOS下Date解析出NaN导致崩溃小程序里最常见的一个隐蔽问题是用户从后端或分享参数里拿到一个日期字符串比如“2024-02-10”然后直接new Date(2024-02-10)。在Android上一般能正常解析但在iOS上会返回Invalid Date因为iOS的JavaScriptCore对这种带横线、且不带时间的日期字符串兼容性不如Android。如果后面再做月份、日期的取值得到NaN农历转换结果直接崩掉。我现在的习惯是只要是从字符串拿日期一律手动split再走Date.UTC构造数字日期不直接new Date(string)。或者干脆在后端返回时就强调只传year、month、day三个数字字段。这样能避开绝大多数奇奇怪怪的解析兼容性问题。4.3 闰月结果的显示逻辑容易写乱公历转农历返回结果里“闰月”是一个很容易出错的地方。有些代码会返回lunarMonth5isLeapMonthtrue表示“闰五月”这个没问题但有些代码返回lunarMonth5同时又在month前面加一个“闰”结果变成了“闰五月”还是“闰六月”还是要看具体的月份字段是否指闰月前的那个月。我的建议是对外返回结果时直接增加一个lunarMonthName字段内部把中文月名拼好比如“闰四月”。不要让业务侧自己再拼因为业务侧对“闰月”概念不熟很容易出错。另外显示农历日期时闰月年份只有十三个月排序始终是“四月、闰四月、五月”而不是“四月、五月、闰四月”。迭代月份时循环到闰月位置必须连续处理正常月和闰月。4.4 1900年之前和2100年之后的数据缺失大多数公开的农历数据表只覆盖1900到2100。如果用户跨过这个范围算法会算出错误结果或者因为数组越界直接取到undefined。小程序里如果做查询类的日历建议在入口处做一次年份范围校验。如果用户输入超出范围明确提示“当前版本支持1900年至2100年”而不是让他看到一团乱码。从安全角度来说也可以在数据表之外做“线性外推”但农历闰月的规律非常复杂外推结果误差很大并不推荐。为了避免误导用户宁可提示范围也不要硬算。4.5 包体积和计算性能农历工具类虽然不大但完整数据表加中文映射如果写得冗余也可能到20KB以上。对于需要极致控制包体积的小程序可以对LUNAR_INFO做一次字符串压缩然后在需要时再拆解。比如把十六进制数组转成一段连续字符串体积能再小一些。性能方面单次转换的循环次数最多也就两百多次对小程序运行时来说微乎其微。真正需要优化的是大批量计算场景比如一次生成30天的月历可以只计算每月1日的偏移基准然后再逐天累加而不是对每一天都重新从1900年循环计算一次那样会浪费不必要的CPU时间。4.6 不要忽略“立春”和生肖的差异如果业务只是显示农历日期生肖不是必需字段但如果要显示生肖很多资料会说“属相按农历正月初一换”也有说法按立春换。这其实和历法规则有关不同的通行口径会给出不同结果。我做的项目里用户在意的是“春节以后才换属相”因为这是大众最容易理解的规则。如果产品经理非要“立春换属相”就要单独再算节气农历转换函数不直接提供这个能力。这里的建议是和产品确认清楚口径否则用户会来投诉“为什么元旦过了还没换属相”。最后再分享一个小经验我在实际开发中踩过几次坑之后给农历工具类定了一个启动自检逻辑上线前跑一遍从1900年到2100年每年正月初一的反向校验把转换结果再转换回公历看是否对得上。这个自检不需要在真机跑直接在开发者工具里写一个简单测试脚本就行能发现数据表复制粘贴错位、位运算方向写反等一系列问题。另外业务里如果只是显示“今天农历”强烈建议在onShow里重新计算一次而不是只在onLoad里计算。因为小程序从后台切回前台日期可能已经跨天如果还保留旧数据用户会看到一个没有更新的农历日期。这个细节不大但直接影响体验。微信小程序获取阴历本质上就是一个“历法数据表 偏移计算”的问题。理解了原理后面无论切换平台还是换成H5版本都能很快复用同一套代码。希望这篇记录能帮你少走一点弯路。