直接上手一个实战小项目聊个经验:做“发票样板 H5”。这类页面在我手头接过好几回,看着简单,实则有不少坑。它既不是完整的开票系统,也不是复杂的打印插件,就是一个能在手机上看、能拿到打印机上打的发票版式展示页。经常用来做商户后台的发票预览、客户查收电子发票前的核对页,或者给业务方确认版式用的样板页。适合刚入门前端的新手练手,也适合在现成项目里快速塞一个样板页的开发者参考。
我做这类页面最常用的方案就是原生 HTML + CSS + JavaScript,不用框架、不用构建工具。原因很直接:发票样板页通常挂在已有的后台系统或者运营后台里,一个纯静态页面往往最省事。打开即用、改起来快,扔到内网Web服务器就能访问,还不用专门接接口联调。如果数据是写死或轻量获取的,原生方式完全够用,而且在打印场景下,原生CSS的还原度往往比前端框架好控制。
1. 设计思路与需求拆解
1.1 先搞清楚这个页面到底要解决什么问题
发票样板H5和普通页面不太一样,它一般有三个明确用途。第一是“预览”,给财务、业务或客户看版式的视觉效果,确认抬头、明细、金额这些位置对不对;第二是“打印”,部分场景要求直接在手机上或者连蓝牙打印机,把样板页打出来;第三是“数据核对”,发票号码、金额、税额、价税合计是不是和后台数据库一致。
也就是说,这个页面天然带有“展示”和“输出”双重属性。展示要在手机、电脑上都好看,输出要保证打印不走样。
我接到需求时,第一件事不是写界面,而是和需求方确认几个关键信息:发票类型是什么,是增值税普通发票、电子发票还是卷式发票;数据来源是静态还是后台接口;打印机是普通A4纸还是热敏纸;是否需要防伪码和发票专用章展示。这些直接决定HTML结构和样式的写法。
有一个容易被忽略的点:发票样板在手机上展示时,用户看的其实是“缩小版”的完整版式。手机屏幕窄,发票又天生是横向信息较多的版式,如果按真实比例去缩放,字会小得看不清。所以页面要提供两种模式:手机浏览模式和打印预览模式。前者做成卡片式、信息分组、字号放大;后者严格按纸质发票比例排版。
1.2 技术选型:为什么我用原生三件套而不是框架
对我来说,发票样板页的关键诉求是“还原度和可控性”。原生HTML能精确控制每个节点的结构,CSS能直接用@media print写打印样式,JavaScript负责数据渲染和交互逻辑。框架当然也行,但这里明显有点“杀鸡用牛刀”。
打个比方:框架像精装修房,拎包入住,但你想改承重墙很麻烦。原生像毛坯房,什么都得自己动手,但墙想往哪儿砌往哪儿砌。发票样板对接重的是样式细节,比如表格线、字体间距、数字对齐、分页控制,这些恰恰是框架的封装层容易掣肘的地方。
如果需求方说“以后可能要对接开票系统接口”,我也会先不引入框架,而是把数据和视图解耦。数据在JS里用对象维护,渲染用函数生成,后续就算接接口,改数据获取层就行,视图层不用动。这是我做这个项目最核心的一个设计策略。
1.3 发票样板的标准信息结构
发票样板虽然类型多,但信息结构总体一致。我整理一下常见字段,实际开发时按需取用:
- 票面头部:发票类型、发票代码、发票号码、开票日期、校验码或防伪码
- 购买方信息:名称、纳税人识别号、地址电话、开户行及账号
- 销售方信息:名称、纳税人识别号、地址电话、开户行及账号
- 明细区:项目名称、规格型号、单位、数量、单价、金额、税率、税额
- 汇总区:合计金额(小写)、合计税额(小写)、价税合计(小写)、价税合计(大写)
- 底部信息:备注、收款人、复核人、开票人、销售方盖章、发票专用章
有些样板还有收款人、复核人、开票人三个字段,千万别漏,财务对这个很敏感。我第一版就漏了“复核人”,被打回来一次。
2. 发票模板的HTML结构与样式设计
2.1 骨架结构:为什么明细区一定要用table
先看一段我常用的HTML骨架,去掉业务字段后的关键结构:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>发票样板</title> <link rel="stylesheet" href="invoice.css"> </head> <body> <div class="page invoice-card"> <div class="invoice-header"> <h1 class="invoice-title">增值税电子普通发票</h1> <p class="invoice-code">发票代码:<span id="invoiceCode">12345678</span></p> <p class="invoice-no">发票号码:<span id="invoiceNo">98765432</span></p> <p class="invoice-date">开票日期:<span id="invoiceDate">2024-01-15</span></p> </div> <div class="invoice-parties"> <div class="party buyer"> <h2>购买方</h2> <p>名称:<span class="buyer-name"></span></p> <p>纳税人识别号:<span class="buyer-tax"></span></p> </div> <div class="party seller"> <h2>销售方</h2> <p>名称:<span class="seller-name"></span></p> <p>纳税人识别号:<span class="seller-tax"></span></p> </div> </div> <table class="invoice-table"> <thead> <tr> <th>项目名称</th><th>数量</th><th>单价</th> <th>金额</th><th>税率</th><th>税额</th> </tr> </thead> <tbody id="invoiceItems"></tbody> </table> <div class="invoice-total"></div> <div class="invoice-remark"></div> <div class="invoice-footer"></div> </div> <script src="invoice.js"></script> </body> </html>明细区我用的是table而不是div模拟。原因非常实际:打印时表格的结构稳定性是最好的,不会因为内容多少而错位,而且thead、tbody天然支持跨页重复表头,这在明细项目多的时候很有用。
发票零件区的invoice-code、invoice-no这些字段都用固定宽度和字间距处理。这里有个小讲究:发票号码看起来要“稀疏”一些才像真的,所以我单独给号码设置letter-spacing: 2px,让数字之间间距拉开。
2.2 屏幕样式:手机上不缩放、不模糊
屏幕样式考虑的是浏览体验,和纸质打印是完全两套逻辑。手机上的发票样板要有“卡片感”,不能让人感觉在强行看一张缩小的纸。
我常用的几个CSS要点:
.invoice-card { background: #fff; border-radius: 12px; padding: 16px; margin: 12px auto; max-width: 420px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); } .invoice-header { display: flex; flex-wrap: wrap; justify-content: space-between; font-size: 12px; } .invoice-table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 12px 0; } .invoice-table th, .invoice-table td { border: 1px solid #d9d9d9; padding: 6px 4px; text-align: right; } .invoice-table th:first-child, .invoice-table td:first-child { text-align: left; }为什么要max-width: 420px?因为手机屏幕一般就是375到430物理像素,420px几乎是完美适配区间。再宽,卡片在手机上会顶到边缘;太窄,数字多的行会换行。
另外我强烈建议把基础字体切到系统字体栈,不要用网络字体。因为发票展示页通常在用户的内网或者微信环境里,加载外部字体又慢还有版权问题,而且打印时字体嵌入特性在不同浏览器里不可控。系统字体渲染快、兼容性最好。
2.3 打印样式:从屏幕到纸张的转换
打印样式是发票样板的重头戏,也是和普通页面差异最大的地方。我的习惯是单独写一份打印专用样式区块,用@media print包裹起来。
核心思路是“去掉一切不适合纸张的元素”。卡片阴影、圆角、背景色全部删除,页面边距清零。纸张上发票要绝对平整,所以打印样式里做的是“重置”而非“微调”。
@media print { @page { size: A4 portrait; margin: 0; } body { background: #fff !important; margin: 0 !important; } .invoice-card { box-shadow: none; border-radius: 0; margin: 0; padding: 5mm; max-width: 100%; } .no-print { display: none !important; } .invoice-table th, .invoice-table td { font-size: 10pt; padding: 3pt; } }这里@page的margin: 0是为了让后续内容从纸张最顶部开始,然后通过padding控制内容与纸张边缘的安全距离。实际使用纸的时候,不同打印机会有不可打印区,所以我会建议把内容区留出至少5毫米的边距,而不是贴死边缘。
还有一个细节:发票打印出来一般不需要全屏彩色,票面文字用黑色,表格线用浅灰色。在打印样式里我会把颜色统一降为黑灰,避免喷墨打印机打出来颜色发闷。
3. 动态数据渲染与金额计算
3.1 数据结构先行,页面布局跟随
做这个页面前,我会先和后台或者接口文档确认返回的数据结构,再决定前端渲染方式。下面是一份模拟数据,基本覆盖了常见字段:
const invoiceData = { type: "增值税电子普通发票", code: "123456789012", no: "987654321", date: "2024-01-15", checkCode: "12345678901234567890", buyer: { name: "某科技有限公司", taxNo: "91110000XXXXXXXXXX", address: "北京市朝阳区某路某号", bank: "招商银行北京分行 1109XXXXXXXXX" }, seller: { name: "某信息技术服务中心", taxNo: "91110000XXXXXXXXXY", address: "北京市海淀区某街某号", bank: "工商银行北京分行 0200XXXXXXXXX" }, items: [ { name: "软件服务费", spec: "", unit: "次", qty: 1, price: 8000.00, rate: 0.06 }, { name: "技术咨询服务费", spec: "", unit: "时", qty: 12, price: 500.00, rate: 0.06 } ], remark: "对公转账", drawer: "张某某", reviewer: "李某某", payee: "王某某" };我把数据定义成对象,就是为了方便渲染函数一次性读取。每个字段名要尽量跟后台返回的key保持一致,这样后面接接口时只需要调整字段映射,不用重写渲染逻辑。
3.2 渲染函数与模板拼接
渲染用基本的DOM操作加模板字符串拼接就够,不需要虚拟DOM。核心函数是renderInvoice(data),它负责把数据写到页面各个节点上。
function renderInvoice(data) { document.querySelector('.invoice-title').textContent = data.type; document.getElementById('invoiceCode').textContent = data.code; document.getElementById('invoiceNo').textContent = data.no; document.querySelector('.buyer-name').textContent = data.buyer.name; document.querySelector('.buyer-tax').textContent = data.buyer.taxNo; // 明细渲染 const tbody = document.getElementById('invoiceItems'); tbody.innerHTML = data.items.map(function(item) { const amount = (item.qty * item.price).toFixed(2); const tax = (amount * item.rate).toFixed(2); return ` <tr> <td>${item.name}</td> <td>${item.unit}</td> <td>${item.qty}</td> <td>${item.price.toFixed(2)}</td> <td>${amount}</td> <td>${(item.rate * 100).toFixed(0)}%</td> <td>${tax}</td> </tr>`; }).join(''); }注意到我用toFixed(2)对金额做了格式化。这里有个坑:JavaScript浮点运算精度问题。比如0.1 * 0.2会得到0.020000000000000004,直接乘完再toFixed在某些情况下会得到错误结果。所以我建议所有金额计算都提前转成“分”做整数运算,或者至少用Math.round包裹关键步骤。
明细里税额计算尤其要注意:税额 = 金额 × 税率,这里的金额是含税单价×数量后的含税金额。不同发票类型的口径可能不一样,有的按含税算,有的是不含税价加税额,做之前和财务确认清楚,别想当然。
3.3 小写金额与中文大写转换
发票票面上既有小写金额又有大写金额。小写展示容易处理,直接格式化两位小数。大写的转换是个基础但容易出错的功能,我写过一版常用的转换函数贴出来供参考:
function toChineseCapital(n) { const digits = ["零","壹","贰","叁","肆","伍","陆","柒","捌","玖"]; const units = ["","拾","佰","仟"]; const bigUnits = ["","万","亿"]; const isNegative = n < 0; n = Math.abs(n); const [intPart, fracPart = ""] = n.toFixed(2).split("."); let result = ""; const intLen = intPart.length; let zeroFlag = false; for (let i = 0; i < intLen; i++) { const digit = parseInt(intPart[i]); const unitIndex = intLen - i - 1; const bigUnitIndex = Math.floor(unitIndex / 4); const unitPos = unitIndex % 4; if (digit === 0) { zeroFlag = true; } else { if (zeroFlag) { result += "零"; zeroFlag = false; } result += digits[digit] + units[unitPos]; } if (unitPos === 0 && bigUnitIndex > 0) { result += bigUnits[bigUnitIndex]; zeroFlag = false; } } if (!result) result = "零"; let cnFrac = ""; const jiao = parseInt(fracPart[0] || "0"); const fen = parseInt(fracPart[1] || "0"); if (jiao > 0) cnFrac += digits[jiao] + "角"; else if (fen > 0) cnFrac += "零"; if (fen > 0) cnFrac += digits[fen] + "分"; if (!cnFrac) cnFrac = "整"; return (isNegative ? "负" : "") + result + "元" + cnFrac; }函数不长,但覆盖了整数部分、小数部分、负数、零值等常见情况。发票里还经常要求“大写金额”单独靠右、突出显示。这块样式上我会用更大的字号、加粗,同时预留好“金额发生变动的可能性”,因为明细加减时合计数字会动,渲染函数要一起刷新。
4. 移动端适配与打印适配的实操要点
4.1 移动端:从viewport到字体缩放的完整链路
移动端适配我用的是rem + vw的组合。在页面头部设置最大宽度,正文用百分比或rem,字体用自适应。一个简单的适配脚本:
function adapter() { const width = document.documentElement.clientWidth; const scale = Math.min(width / 375, 1.3); document.documentElement.style.fontSize = 100 * scale + 'px'; document.documentElement.style.setProperty('--page-scale', scale); } window.addEventListener('resize', adapter); adapter();为什么用375做基准?因为这是绝大多数手机的基准宽度设计稿。设成100px = 1rem,方便换算,字体大小写成0.12rem就等于 12px。最大倍率我限制到1.3,防止在平板上字大得离谱。
另一个细节是页面顶部放一个明显的“当前为预览效果”提示条,手机上浏览时用。这样既满足预览需要,也提醒用户这不是最终的发票文件。很多样板页被客户截图后误当正式发票使用,这个提示条能省去不少沟通成本。
4.2 打印适配:控制分页与边距
打印适配最容易踩的坑,是“打印出来的内容被切在两页中间”。发票明细行数多的时候,表格可能跨页,而表格跨页后第二页没有表头,视觉上就很乱。
解决办法有两个。一是利用CSS的thead自动重复表头,这在现代浏览器里对打印是默认有效的。
.invoice-table thead { display: table-header-group; } .invoice-table tr { page-break-inside: avoid; }二是给整页指定最小高度,明细不多时一页打满,明细多时从第二页开始继续,保证第一页永远干净。
page-break-inside: avoid很关键,让每一行尽量不被劈开。打印内容超过一页时,需要人为添加一个分页点。我常规做法是明细区之后加一个page-break-before: always的容器,或者在JS里动态计算行数,超过某阈值就给下一部分前插入分页标记。
4.3 防伪码、二维码与发票专用章的呈现
发票样板一般要显示防伪码、二维码和发票专用章。这三样东西在实现层面有不同策略。
防伪码直接渲染成数字文本就行,但要加间距和底纹,视觉上有个“验证区”的感觉。二维码最简单的方式是先让设计给一张占位图,正式开发接接口时返回一个图片URL就行。不过做一个不需要后台、纯前端生成二维码的版本也很简单,用现成库生成dataURL,渲染到<img>标签里,样板页就能完全独立跑起来。
发票专用章这一点尤其容易踩坑。一般来说不能用普通图片直接贴上去,因为发票专用章需要椭圆形红色边框,内部有公司名称和税号,不同公司印章内容必然不同。我在样板页里的策略是:默认展示一个占位印章,样式用CSS画一个椭圆红色边框,文字从数据字段动态填充。这样既保证了视觉效果,又不依赖外链图片。
.stamp { display: flex; align-items: center; justify-content: center; width: 120px; height: 80px; border: 3px solid #e02020; border-radius: 50% / 40%; color: #e02020; font-size: 10px; text-align: center; transform: rotate(-8deg); opacity: 0.7; }印章稍微旋转一点、颜色降低透明度,视觉上更像盖上去的。这是我在多次被吐槽“印章太假”之后调出来的经验值。不过提醒一下,正式接入真实打印时,印章必须是清晰的纯红色,不要旋转倾斜,样式会单独再调回来。
5. 常见问题与排查技巧实录
5.1 手机预览字太小,打印样式不对
这是发票样板H5最多人问的问题。手机预览字太小,通常是因为viewport设置不对或者基础字号定太小。排查时先看HTML头部有没有写入width=device-width,再看CSS的基准字号。我用的是12px起,移动端最大不超过14px,低于10px就该考虑是不是数据太多导致布局被压缩了。
打印样式不对,第一步先在 Chrome 开发者工具里打开“渲染→模拟CSS媒体打印”,直接看打印样式的渲染结果,而不要反复去点“打印预览”。浏览器里预览09的那个打印对话框会缓存部分样式,调试效率很低。
5.2 金额浮点误差,导致最终合计数不对
我在第三部分提过这个坑,这里再细说一次。比如数量3、单价0.1元,3乘以0.1在JS里得到的是0.30000000000000004,直接toFixed(2)会变成0.30,如果后续再用这个数做整数相加,误差可能被放大。
常规做法有两种。一种是把金额先转成分:Math.round(price * 100) * qty,得到总和后再除以100。另一种是封装一个精确乘除函数,内部用字符串补齐位数来完成计算。对于发票样板这种体量的页面,用第一种就够。我习惯所有金额字段统一走格式化函数,不在业务代码里直接写toFixed。
5.3 明细行多时跨页打印,表头丢失
这个问题在前面已经给了方案,这里再强调一个细节:跨页后第二页顶部的表头能不能重复显示,取决于表格本身是否为标准的thead结构,而不是用普通div模拟的表头。我早期版本用div模拟表格,打印一多就出问题,换成真正的table后基本无忧。
还有一种情况是跨页以后最后一行被截断,只显示上半行。这需要给行设置page-break-inside: avoid,如果还不行,检查浏览器是否有全局样式覆盖了这个属性。旧版浏览器对分页的支持比较弱,建议直接以Chrome内核为主做兼容。
5.4 二维码和印章在移动端模糊
二维码模糊一般是二维码图片本身分辨率不够,让设计出矢量图或高倍图,前端渲染时固定尺寸就行。印章模糊通常是用了位图png,而印章带透明通道且外圈细,缩放时边缘容易糊。我换成CSS绘制的占位印章后问题迎刃而解,正式对接时再替换成高清图片,这个方案应对开发阶段的样板展示足够了。
5.5 原生页面请求跨域,数据加载不出来
如果样板页的H5部署在A域名,后台数据接口在B域名,就会出现跨域。开发阶段最简单的方式是让前端先用模拟数据渲染,同时让后台管线配置跨域头。不要自己悄悄在页面上绕过跨域,这不是前端能完全解决的事,合规层面要配合后台一起处理。
我实际项目里常用的调试手法是:把模拟数据单独拎成一个mockData.js文件,页面加载时判断是否存在全局USE_MOCK标志,为true就渲染模拟数据,为false就去请求接口。这样联调前后端可以并行,不用互相等。
写在最后,几个让我印象深刻的细节
发票样板H5做完我最大的感受是:它不像常见营销页那样考验创意,而是在“还原度”和“细节”上抠得很紧。任何一个字段位置不对、字号不适配,业务方一眼就能看出“不对劲”。我做过一版自以为很完美的样式,结果业务方说发票号码应该比代码大一些,我量了一下,确实差了2像素,改完才过关。
另外有个小技巧值得分享:请保持一份独立的纯静态样板文件,不依赖任何数据接口。这样不管是给需求方演示,还是给设计核对样式,随时打开一个html就行。开发环境里充满各种请求失败、接口异常,纯静态版本能帮你排除大量干扰,专注到版式本身。
发票样板这个需求虽然看着小,但它其实浓缩了移动端适配、打印排版、数据渲染、浮点计算、跨域交互等多个前端基础技能。真做一遍,收获比想象中大得多。如果你也在做类似页面,按我上面的思路梳理一遍,然后把所有字段和样式列成清单核对,相信能少走不少弯路。