纯JS实现个税模拟器:从累计预扣法到实战代码
个税模拟器计算演示版纯js源码分享——看到这个标题的时候我第一反应是终于有人愿意把这件“看着简单、做起来全是细节”的事情单独拆出来了。个税计算器本质上不是代码量的问题而是税法规则怎么翻译成程序逻辑的问题。如果只是做“工资减5000再乘税率”结果会和工资条差得离谱但如果把累计预扣法、专项附加扣除、全年综合所得这些概念全塞进去又容易把Demo做成一个连自己都不想维护的庞然大物。这个“计算演示版”的定位其实很聪明不追求覆盖所有真实场景而是用纯JS、单页面、零依赖的方式把计税逻辑跑通让读者看得懂每一步计算也能直接复制源码改着玩。这篇文章我打算从计税规则拆起再讲核心函数怎么写最后把我在实际调试中踩过的坑一并整理出来适合前端初学者拿来练手也适合HR、财务或普通打工人快速估算自己的税后收入。1. 个税模拟器的核心价值先搞懂计税规则再谈写代码1.1 一个Demo背后藏着的完整税制逻辑很多人以为个税计算就是简单的分段函数月薪减去5000落在哪个区间就按哪个税率算。这个理解在老个税时代勉强成立但在累计预扣法之下已经不太准确了。新个税的核心是“综合所得”。工资薪金、劳务报酬、稿酬、特许权使用费合并成综合所得按纳税年度合并计算。日常发工资时公司不会等到年底才扣税而是每个月先“预扣预缴”到次年再汇算清缴、多退少补。预扣预缴用的就是累计预扣法思路是把截至本月的所有收入和扣除项都累加起来按年度税率表算出“累计应预扣税额”再减去之前已经预扣过的税额剩下就是本月要交的。举个例子会更直观月薪15000元、没有社保也没有专项附加扣除的情况下每月应纳税所得额是10000元。如果按“月度估算”的做法10000元落在10%税率档月税大概(10000×12×10%-2520)/12790元。但累计预扣法下前面几个月的累计应纳税所得额可能还在3%档每月只预扣300元上下等到后期才逐渐提到10%档。全年总税额虽然一致每个月的数字却是前低后高。这种走势不亲自动手跑一遍数据很难体会到。所以这个演示版的价值不只是算出一个数而是把“月度估算”和“累计预扣”这两种口径都做出来让人直观看到差异。源码里我会同时提供两个计算函数你可以切换着看结果。1.2 哪些人会用到这类计算器从我在实际工作里接触到的需求来看个税模拟器的受众远比想象中杂。前端开发者拿它当练手项目能同时锻炼表单交互、数据处理和边界判断做薪酬的HR朋友需要一个不依赖第三方接口的内部测算工具输入工资就能快速换算出税后成本准备跳槽的普通打工人更是需要它来模拟“涨薪2000到底到手能多多少”——这个问题的答案往往和直觉有差距因为税率是跳档的。演示版的意义在于“轻”。不需要登录、不需要数据库、不需要后端服务一个HTML文件就能在浏览器里跑起来。如果你只是想快速验证一段计税逻辑纯JS方案是最省事的如果你想把它嵌到内部管理系统里把计算函数抽出去换个UI外壳就能用。后面我会专门讲函数怎么抽。2. 算准个税的五个关键输入收入、减除、社保、专项附加、税率表2.1 基本减除费用与社保公积金扣除个税计算的起点是“应纳税所得额”不是工资全额。先要减去每月5000元的基本减除费用也就是常说的“起征点”。这5000元不分城市、不分行业、不看学历每个人每个月都有。接着是“专项扣除”主要指个人缴纳的养老保险、医疗保险、失业保险和住房公积金通常统称“三险一金”。这部分因为是税前扣除所以会直接影响计税基数。注意不同城市的社保比例和公积金缴存比例差别很大公积金的比例可以从5%到12%哪怕税前工资一样税后到手也可能差几百块。演示版里我把社保和公积金合并成一个输入框按“每月个人缴纳部分”整体填写这样既简化了界面又不会丢失计算精度。还有“税延养老保险”等特定项目普通模拟器一般不考虑感兴趣的话可以在源码里预留一个“其他扣除”字段逻辑上加一行代码就行。2.2 专项附加扣除的录入方式专项附加扣除是很多人搞不清楚的部分。子女教育、继续教育、大病医疗、住房贷款利息、住房租金、赡养老人、3岁以下婴幼儿照护一共七类每类有各自的扣除标准和适用条件。演示版如果把这些做成七个输入框界面会显得很臃肿而且政策细节随时可能调整维护成本很高。所以更聪明的做法是合并成一个“每月专项附加扣除总额”。用户按自己的实际情况估算一个总额填进去计算函数只关心总数是多少并不关心它是哪几项组成的。这样代码保持简洁同时不牺牲通用性。我在实际使用中习惯给自己设一个参考值有房贷的填1000有小孩上学的再填2000赡养老人如果是独生子女再填2000大致加总就行。如果你要做一个团队内部用的版本可以考虑把这些扣除项分开但我始终建议默认视图保持“总额模式”需要明细再展开一个高级面板。具体政策标准会随政策变化演示版里不写死以最新口径为准即可。2.3 税率表与速算扣除数的数学原理综合所得适用的年度税率表是七级超额累进税率从3%到45%。表格长这样年度应纳税所得额元预扣率速算扣除数元不超过36000元3%0超过36000元至144000元10%2520超过144000元至300000元20%16920超过300000元至420000元25%31920超过420000元至660000元30%52920超过660000元至960000元35%85920超过960000元45%181920速算扣除数看起来像个魔法数字实际上只是为了避免分段计算的繁琐。以第二档为例分段算税额是“36000×3% (应纳税所得额-36000)×10%”化简之后就是“应纳税所得额×10%-2520”这个2520就是速算扣除数。你用分段累加和用全额乘税率再减速算扣除数结果完全一致但后者在代码里只需要一次乘法一次减法效率和可读性都好得多。代码里最关键是这张表的数据结构。我建议用数组加对象来存每一项包含上限、税率和速算扣除数这样后续增删档位都不需要改计算逻辑。千万不要把档位写死在if-else里否则每次政策调整都要翻代码。3. 纯JS实现核心函数、页面组装与源码细节3.1 页面骨架与交互设计这个演示版的目标是“开箱即用”所以一个HTML文件就够。页面结构分上下两块上半部分是输入区下半部分是结果区。输入区至少需要四个字段每月税前工资、每月社保公积金个人缴纳部分、每月专项附加扣除总额、计算模式选择月度估算或累计预扣。结果区需要展示月度应纳税所得额、年度应纳税所得额、适用税率、速算扣除数、本月应缴个税、到手收入。交互上我强烈推荐用input事件实时刷新不需要点击“计算”按钮。用户一改数字结果就跟着变这种即时反馈特别适合模拟器能让人反复拖动数字观察税率的跳档变化。用change事件在失焦后才更新体验会差一大截。3.2 月度估算模式的计算函数月度估算模式适合快速出数逻辑是“按当前月收入推算全年水平再均摊回每月”。核心代码是这样// 年度综合所得税率表 const TAX_BRACKETS [ { upper: 36000, rate: 0.03, deduction: 0 }, { upper: 144000, rate: 0.10, deduction: 2520 }, { upper: 300000, rate: 0.20, deduction: 16920 }, { upper: 420000, rate: 0.25, deduction: 31920 }, { upper: 660000, rate: 0.30, deduction: 52920 }, { upper: 960000, rate: 0.35, deduction: 85920 }, { upper: Infinity, rate: 0.45, deduction: 181920 } ]; function getBracket(annualTaxable) { return TAX_BRACKETS.find(item annualTaxable item.upper); } function estimateMonthlyTax(monthlyIncome, socialSecurity, extraDeduction) { const monthlyTaxable monthlyIncome - 5000 - socialSecurity - extraDeduction; if (monthlyTaxable 0) { return { monthlyTaxable: 0, annualTaxable: 0, rate: 0%, quickDeduction: 0, tax: 0, takeHome: monthlyIncome - socialSecurity }; } const annualTaxable monthlyTaxable * 12; const bracket getBracket(annualTaxable); const annualTax annualTaxable * bracket.rate - bracket.deduction; const monthlyTax Math.round(annualTax / 12); return { monthlyTaxable, annualTaxable, rate: (bracket.rate * 100).toFixed(0) %, quickDeduction: bracket.deduction, tax: monthlyTax, takeHome: monthlyIncome - socialSecurity - monthlyTax }; }注意这里的关键是“把月度应纳税所得额乘以12换成年化”。比如月薪15000、社保1000、专项附加1000月度应纳税所得额就是8000元年化96000元落在第二档10%年税就是96000×10%-25207080元月均590元。这个数字适合用来估“全年平均税负”但不等于每个月工资条上的数字。3.3 累计预扣模式的实现如果你想让模拟器更贴近真实工资条就必须实现累计预扣法。它的输入不再是“本月收入”而是“截至本月的累计收入”“累计社保”“累计专项附加”和“累计已预扣税额”。function calcCumulativeTax({ cumulativeIncome, cumulativeSocialSecurity, cumulativeExtraDeduction, monthCount, paidTax }) { const monthlyThreshold 5000; const cumulativeTaxable cumulativeIncome - monthlyThreshold * monthCount - cumulativeSocialSecurity - cumulativeExtraDeduction; if (cumulativeTaxable 0) { return { currentTax: 0, cumulativeTax: 0, cumulativeTaxable: 0 }; } const bracket getBracket(cumulativeTaxable); const cumulativeTax Math.max( Math.round(cumulativeTaxable * bracket.rate - bracket.deduction), 0 ); const currentTax Math.max(cumulativeTax - paidTax, 0); return { currentTax, cumulativeTax, cumulativeTaxable, rate: (bracket.rate * 100).toFixed(0) %, quickDeduction: bracket.deduction }; }这个函数里最容易被忽略的是累计减除费用不是简单乘以当前月份数而是“5000×月数”。1月用一次500012月用60000这样全年累计减除费用正好达到6万元。用这个函数逐月跑一遍你会看到每个月的税额变化。我录了一组数据供参考月薪15000、社保1000、专项附加10001月应纳税所得额8000累计应纳税8000×3%240元到5月累计应纳税所得额40000累计应纳税40000×10%-25201480元减去前4个月已预扣的960元5月应缴520元。这种起伏就是累计预扣法的典型表现。演示版可以在输入区加一个“已预扣税额”字段每次算完当前月把结果填回去就能连续滚动计算下去。3.4 组装一个可运行的迷你Demo把页面串起来并不复杂。HTML部分用最朴素的原生表单结果区域用四个只读字段展示。核心绑定就两段input typenumber idmonthlyIncome value15000 / input typenumber idsocialSecurity value1000 / input typenumber idextraDeduction value1000 / select idcalcMode option valueestimate月度估算/option option valuecumulative累计预扣/option /selectconst inputs [monthlyIncome, socialSecurity, extraDeduction]; inputs.forEach(id { document.getElementById(id).addEventListener(input, updateResult); }); document.getElementById(calcMode).addEventListener(change, updateResult); function updateResult() { const income parseFloat(document.getElementById(monthlyIncome).value) || 0; const social parseFloat(document.getElementById(socialSecurity).value) || 0; const extra parseFloat(document.getElementById(extraDeduction).value) || 0; const mode document.getElementById(calcMode).value; let result; if (mode estimate) { result estimateMonthlyTax(income, social, extra); } else { // 累计预扣模式下演示版默认按第1个月计算便于理解 result calcCumulativeTax({ cumulativeIncome: income, cumulativeSocialSecurity: social, cumulativeExtraDeduction: extra, monthCount: 1, paidTax: 0 }); } // 把result里的字段渲染到页面上 }这里的细节是parseFloat加||0因为空字符串转成数字会变0但NaN一旦出现会污染所有下游结果。初始值给一个合理的demo数据比如收入15000、社保1000、专项1000用户打开就能看到一组有意义的数字比空表单更容易上手。样式方面不用过度设计用flex布局居中结果区数字加大加粗税率档位变化时用CSS类切换颜色就能有不错的观感。一个真正吸引人的演示版交互反馈比视觉特效重要得多。4. 常见问题与排查实录4.1 为什么模拟器和工资条对不上这是被问得最多的问题。原因通常有三个一是公司使用的社保公积金基数和你的估算不同二是专项附加扣除申报时间有延迟比如年中才补录会导致前面月份补扣三是计税方式差异少数公司内部系统是按简化月度表估算扣缴而不是严格用累计预扣法。累计预扣法还有个容易忽略的细节年度中间换工作新公司不知道你之前的累计收入和已预扣税额会重新从第一个月开始累计。这会导致全年实际预扣税额低于应缴额次年汇算时需要补税。这也是为什么很多人第一次做汇算清缴会莫名其妙补一笔钱。你可以在模拟器里加一个“上家单位已预扣税额”输入项把跳槽场景也模拟进去这样不光能算月税还能估算换工作的税务影响。这个扩展特别适合对薪酬敏感的技术人。4.2 年终奖单独计税怎么模拟年终奖是另一个常见坑。优惠政策有效期内年终奖可以选择单独计税也可并入当年综合所得计税谁能少交就选谁。单独计税的方法是把年终奖除以12得到“月均奖金”查月度税率表确定级次再用年终奖全额乘以税率减去速算扣除数。月度税率表和年度表不是同一个单独计税用的是另一张月均应纳税所得额税率速算扣除数不超过3000元3%0超过3000元至12000元10%210超过12000元至25000元20%1410超过25000元至35000元25%2660超过35000元至55000元30%4410超过55000元至80000元35%7160超过80000元45%15160对应的函数很简单const MONTHLY_BRACKETS [ { upper: 3000, rate: 0.03, deduction: 0 }, { upper: 12000, rate: 0.10, deduction: 210 }, { upper: 25000, rate: 0.20, deduction: 1410 }, { upper: 35000, rate: 0.25, deduction: 2660 }, { upper: 55000, rate: 0.30, deduction: 4410 }, { upper: 80000, rate: 0.35, deduction: 7160 }, { upper: Infinity, rate: 0.45, deduction: 15160 } ]; function calcBonusTax(bonus) { const monthlyEquivalent bonus / 12; const bracket MONTHLY_BRACKETS.find(item monthlyEquivalent item.upper); return Math.round(bonus * bracket.rate - bracket.deduction); }写代码只要20行但真正的坑在于“对比”。年终奖3.6万元和3.7万元之间可能相差好几百的税因为跨档了。我建议演示版在显示单独计税结果的同时把“并入综合所得”的差额也算出来这样用户才能做出选择。如果只给一个数字很容易误导人。4.3 输入异常与边界处理计算器类工具最常见的翻车点不是算法而是用户输入了奇怪的值。我从自己的调试记录里整理了这几个高发问题第一个是空值。很多浏览器把清空的数字输入框value当作空字符串parseFloat后得到NaN一旦NaN进入计算整个结果区域都变成NaN。解决方法是parseFloat后立即用||0兜底或者判断isNaN后赋0。第二个是负数和超界。用户可能手滑输入负数社保或者社保金额超过工资。我的处理是计算前做一次clamp把负数归零如果社保公积金大于工资提示用户检查输入而不是默默算出一个月薪为负的荒谬结果。第三个是浮点精度。虽然税表里都是整数金额但除法会产出0.1之类的不精确值。所有金额展示前务必Math.round一次保留到元即可没必要显示小数点后两位。第四个是极大值。输入1亿工资时年化应纳税所得额会非常大但税率表最后一项的upper是Infinity所以查找不会出错。可速算扣除数在旁边浮点运算也还稳定只是UI上数字可能过长。演示版可以加一个toLocaleString用于展示提升可读性。5. 从演示版到生产级工具的扩展思路5.1 把税率配置变成数据我在前文已经建议用数组存储税率表如果只在一个HTML文件里用写死没问题。可一旦要长期维护最好把税率表抽成独立JSON或配置项。这样政策档位调整时只改数据不动逻辑比翻代码找if-else高效太多。更进一步可以把社保比例、公积金比例、专项附加上限全部做成配置不同公司、不同城市的人员打开同一个工具看到的是各自适用的参数。配置化是个税计算器从“玩具”走向“内部工具”的第一步。5.2 社保基数按城市差异化社保这块水很深。每个城市都有缴费基数上下限像北京、上海、深圳的社保基数范围差距很大公积金比例也不一样。演示版里用一个总额输入框规避了这个问题但生产工具里至少要支持“自动带出城市参数”。实际做法可以内置一张城市参数表默认社保比例、公积金比例、缴费基数上下限。用户选择城市后自动填充还想手动微调就开放编辑。这个功能不难但信息收集和更新是个持续工作所以演示版不推荐内置太多城市留两三个样例城市就够。5.3 框架迁移与组件化如果你的项目已经用Vue或React完全可以把计算函数抽成独立的纯函数模块不依赖任何DOM操作。原有的estimateMonthlyTax和calcCumulativeTax可以直接复用只是把输入输出变成props和state。我建议函数签名保持纯函数风格入参是一个对象出参也是一个对象内部不碰DOM。这样不仅方便测试也方便未来做单元测试——税务计算这种逻辑给一组输入断言一组输出比什么都可靠。迁移到组件时注意把“展示”和“计算”彻底分离。页面只负责采集输入和渲染结果所有税款计算都交给纯函数。哪怕税表政策变了也只改配置文件页面一行不用动。我在实际使用中发现很多内部工具卡在“能用”和“好用”之间差的往往不是功能而是数据边界处理。个税模拟器这类小工具与其堆功能不如先把计算口径做扎实把异常输入挡住把每个数字的来龙去脉交代清楚。这样无论别人是拿来估算工资还是研究计税逻辑都能快速建立起信任感。最后说一个我常用的验证技巧在源码里加一个“测试用例”按钮自动跑三四组典型数据比如“月薪刚好5000”“月薪15000跨档”“年终奖36000临界点”用断言比对预期结果。每次改完代码点一下全部通过再发布。这个习惯帮我挡住了好几次因为改税表而引入的回归问题。你可以把这个思路也带到其他财务类小工具的开发里去。