Old-School Front-End / 2026 ・ No.07
古法前端入门
第7讲 JavaScript ① ── 基础:写一个计算明细合计的函数
发布
不靠 AI,一行一行亲手写的“古法”前端入门。从第7讲开始,终于轮到 JavaScript 了。
如果 HTML 是骨架、CSS 是衣服,JavaScript 就是肌肉。它的第一份工作,是新井一直用计算器算的明细合计。
值的类型、变量、函数、循环,还有处理金额时一定会踩的“明明是加,却拼起来了”的坑。
这一讲还不碰页面(DOM),只写负责计算的函数,并自己来测试。
本讲的目标
新井前辈,我让 AI 写了合计的计算!……可是大阪出差的合计显示成“0147201280014720 日元”。
古井147 万亿日元的出差啊,财务要吓瘫了。
新井看起来只是把数字全拼在一起了……
古井没错。JavaScript 的“+”有两副面孔。今天就从这里讲起。
当前位置:第7讲
这次要写的是 js/app.js,也就是第1讲定好的文件夹结构里最后一个空房间。里面是只负责计算的 4 个函数:
- toAmount ── 把输入框里的文字(“14,720”或全角的“14720”)转成金额数字
- calcTotal ── 根据明细列表算出合计
- needsReceipt ── 合计在 3 万日元以上时,回答“必须上传收据”
- formatYen ── 把 42240 变成“42,240 日元”的形式
从页面的输入框取值、把合计显示到页面上,是下一讲(第8讲)的内容。把计算和页面分开,就能单独拿出计算来验证。这一讲我们就用自己写的测试来做这件事。
JavaScript 写在哪里
就像用 link 读入 CSS 一样,JavaScript 用 script 元素读入。问题是写在哪里。HTML 从上往下读,所以位置不同,“执行的时机”也不同。我试了三种写法,查看当时 document.body(页面的主体)是否存在。
script 的位置与执行时机(实测)
直接写在 head 里,会在还没读到主体时就执行,去找页面上的部件也找不到。加上 defer(“延后”的意思),就会在 HTML 读完之后执行。从这一讲起,申请表单的 head 里这样写:
<link rel="stylesheet" href="css/custom.css"> <script src="js/app.js" defer></script>
试运行写好的 JavaScript,就在开发者工具的控制台里。Chrome 在 Windows 上按 Ctrl+Shift+J,在 Mac 上按 Cmd+Option+J 打开。在 > 后面输入式子按 Enter,马上就会返回结果。它也能当计算器用,请一边动手一边读。
古井JavaScript 是 1995 年只用了大约 10 天就做出原型的语言。
新井10 天!就这样被用得这么广?
古井所以也有赶工留下的怪脾气。今天就来学怎么和这些脾气相处。
值的类型
JavaScript 的值有类型。把报销里会出现的列出来:
值的类型,以及报销中的例子
关键在于,同样是 14720,数(number)和文字(string)是两回事。而且,从 HTML 输入框取出的值,即使是 type="number" 的输入框,也一定是文字(第3讲看到的 name=value 也全是文字)。值的类型可以用 typeof 查看。
> typeof 14720 < 'number' > typeof '14720' < 'string'
变量:const 与 let
给值起个名字保存起来,就是变量。现在的 JavaScript 分两种写法:
const RECEIPT_LIMIT = 30000; // const:之后不再替换 let total = 0; // let:之后会替换 total = total + 14720; // 可以替换 RECEIPT_LIMIT = 50000; // const 不能替换 → 报错
- 先用 const ── 不打算替换的就用 const。不小心覆盖时,当场报错提醒你
- 需要时才用 let ── 像合计这样一边加一边变的,用 let
- 不用 var ── 旧的写法。网上的老文章以及学了它们的 AI 常用。看到就改成 let 或 const
名字用能看出内容的英文。比起 x 或 data,用 total、items。固定的值(3 万日元)像 RECEIPT_LIMIT 这样用大写,一眼就知道“这是规定里的数字”。
“+”的两副面孔
开头那“147 万亿日元”的真相:
“+”是加,还是拼接?(实测)
“+”两边都是数时做加法,只要有一边是文字,就变成拼接。输入框的值是文字,直接相加的话……
即使合计从 0 开始,0 + '14720' 那一刻就成了文字 '014720',之后就一路拼下去。更麻烦的是,“-”会先把文字转成数再减,所以只有减法能正常工作。所以输入的值,在相加之前一定要转成数。这是本讲最重要的教训。
处理金额时还会遇到其他陷阱,我收集起来实际运行了一遍:
# 在 Chromium 141 的控制台中运行(Node.js 22 结果相同) > '14720' + '12800' + '14720' → '147201280014720' > 0 + '14720' + '12800' + '14720' → '0147201280014720' > '14720' - '12800' → 1920 > Number('') → 0 > Number('1,000') → NaN > parseInt('1,000') → 1 > Number('14720') → NaN > '14720'.normalize('NFKC') → '14720' > 0.1 + 0.2 → 0.30000000000000004 > 42240 + NaN → NaN > NaN === NaN → false > typeof NaN → 'number' > typeof '14720' → 'string' > typeof 14720 → 'number' > typeof null → 'object' > typeof undefined → 'undefined' > typeof [] → 'object'
- Number('') 为 0 ── 空栏默默变成 0 日元。“没填”和“0 日元”应该区分
- parseInt('1,000') 为 1 ── 读到逗号前就停了,一千日元变成一日元
- 全角数字是 NaN ── 中文和日文输入时,全角的“14720”很常见
- NaN(Not a Number) ── 表示“不是数”的特殊值。加上什么都还是 NaN,会把整个合计毁掉。而且 NaN === NaN 是 false,比较时要用 Number.isNaN 或 Object.is
- 0.1 + 0.2 ── 计算机有时无法精确表示小数。金钱用整数处理(日元就用日元,美元就用美分),这是铁律
函数与循环
给合计的计算起个名字,让它能反复使用,这就是函数。
函数的结构
calcTotal 接收的 items,是把表示明细一行的对象(带名字的值的集合)排成一列的数组。
const items = [
{ date: '2026-10-15', item: '新干线(东京→新大阪)', amount: 14720 },
{ date: '2026-10-15', item: '酒店 1 晚', amount: 12800 },
{ date: '2026-10-16', item: '新干线(新大阪→东京)', amount: 14720 },
];
items.length // 3(行数)
items[0].amount // 14720(第 1 行的金额。从 0 开始数)
for (const item of items) 是把数组的内容逐个取出、重复同样处理的写法。我记录了每一次的 total:
每循环一次就增加的 total(运行结果)
新井AI 写的是 items.reduce((a, b) => a + b.amount, 0) 一行搞定。这样不是更帅吗?
古井做的是同一件事。不过以你现在的水平,能解释“为什么能运行”的是 for 吧?与其写一行说不清的,不如写五行说得清的。reduce 等熟练了再用也不迟。
把输入转成金额
为了避开“+”的坑,我们写一个把输入框的文字转成金额的函数 toAmount。用一个混着全角空格、全角数字和全角逗号的、很有现场感的输入试试:
toAmount 内部发生了什么(运行结果)
字符串自带 .normalize()、.trim() 这样方便的工具(方法),可以用点连起来依次使用。最后的 /^[0-9]+$/ 是表示“从头到尾只有数字”的固定写法(正则表达式),第9讲再详细讲。
另一个小心思是用返回值传达“状态”:
- 数(例:14720)── 能读成金额
- null ── 是空栏。与 0 日元区分
- NaN ── 读不成金额(“约1万”“-500”“12.5”等)
接收方可以据此分别提示:null 时“请输入”,NaN 时“金额请填数字”(第9讲的输入校验会用到)。
app.js 的全貌
// 报销系统:计算用的函数(第7讲)
// 还不碰页面(DOM),只负责“输入 → 计算 → 结果”
const RECEIPT_LIMIT = 30000; // 必须上传收据的合计金额(日元)
/**
* 把输入框里的文字变成金额(日元整数)
* 空栏返回 null,无法当作金额读取时返回 NaN
*/
function toAmount(text) {
const s = String(text)
.normalize('NFKC') // 把全角数字(123)变成半角(123)
.replaceAll(',', '') // 去掉千位分隔的逗号
.trim(); // 去掉前后的空白
if (s === '') return null;
if (!/^[0-9]+$/.test(s)) return NaN; // 是否只由数字组成
return Number(s);
}
/** 根据明细数组计算合计金额 */
function calcTotal(items) {
let total = 0;
for (const item of items) {
total += item.amount;
}
return total;
}
/** 是否必须上传收据(合计 3 万日元以上) */
function needsReceipt(total) {
return total >= RECEIPT_LIMIT;
}
/** 把金额变成“42,240 日元”的形式 */
function formatYen(amount) {
return amount.toLocaleString('zh-CN') + ' 日元';
}
- 规定的数字只写一处 ── 3 万日元只在 RECEIPT_LIMIT 里写一次。规定变了也只改一行
- “以上”是 >= ── 规定是“3 万日元以上”就用 >=,“超过 3 万日元”就用 >。一个字符的差别,决定了正好 3 万日元的申请怎么处理
- /** … */ 的说明 ── 在函数前写明接收什么、返回什么。编辑器在鼠标悬停到函数上时会显示出来
- 显示用的形式放到最后 ── 计算一直用数,只在显示到页面前才变成“42,240 日元”的文字。先变成文字再相加,又会“拼起来”
手写的测试
每次都在控制台手动输入来确认函数是否正确,太累了。所以把确认的步骤本身写成代码,这就是测试。也有专门的工具,不过按古法,先从十来行手写的开始。
// 测试
let passed = 0, failed = 0;
function check(label, actual, expected) {
if (Object.is(actual, expected)) {
passed++;
console.log('✓ ' + label);
} else {
failed++;
console.error('✗ ' + label + ' → ' + actual + ' (≠ ' + expected + ')');
}
}
const items = [
{ date: '2026-10-15', item: '新干线(东京→新大阪)', amount: 14720 },
{ date: '2026-10-15', item: '酒店 1 晚', amount: 12800 },
{ date: '2026-10-16', item: '新干线(新大阪→东京)', amount: 14720 }
];
check('合计', calcTotal(items), 42240);
check('明细 0 行', calcTotal([]), 0);
check('3 万日元以上', needsReceipt(42240), true);
check('正好 3 万日元', needsReceipt(30000), true);
check('29,999 日元', needsReceipt(29999), false);
check('带逗号', toAmount('14,720'), 14720);
check('全角数字', toAmount('14720'), 14720);
check('空栏', toAmount(''), null);
check('非数字', toAmount('約1万'), NaN);
check('负数', toAmount('-500'), NaN);
check('小数', toAmount('12.5'), NaN);
check('显示形式', formatYen(42240), '42,240 日元');
console.log('测试: ' + (passed + failed) + ' 项中 ' + passed + ' 项通过');
在浏览器里运行的结果:
✓ 合计 ✓ 明细 0 行 ✓ 3 万日元以上 ✓ 正好 3 万日元 ✓ 29,999 日元 ✓ 带逗号 ✓ 全角数字 ✓ 空栏 ✓ 非数字 ✓ 负数 ✓ 小数 ✓ 显示形式 测试: 12 项中 12 项通过
测试的关键是测分界。3 万日元的规定,就测 29,999、30,000 和以上这三种。“正好”的处理,无论在需求文档还是代码里,都是最容易出错的地方。
请 AI 写代码时,建议连测试一起要,并且自己读这些测试。像“29,999 为 false,30,000 为 true”这样,测试比文字更精确地表达需求。如果测试和规定对不上,那是比代码更应该先改的“理解偏差”。开头那 147 万亿日元,只要有一行“合计是 42240”的测试,上线前就能抓住。
结 — 一页总结与下讲预告
- JavaScript 用 加了 defer 的 script 元素读入。试运行用开发者工具的控制台。
- 值有类型。输入框的值一定是文字(string),可以用 typeof 确认。
- 变量 先用 const,要替换的才用 let。不用 var。
- “+”遇到文字就拼接。输入的值相加前要转成数。注意空栏・全角・逗号・NaN・小数,金钱用整数。
- 计算写成与页面分离的函数,用包含分界的测试来确认。
新井42,240 日元,算出来了!……不过这只是在控制台里吧?页面上的合计栏还是空的。
古井下一讲是 DOM 与事件。一输入合计就变,一点按钮明细就多一行。页面终于要动起来了。
参考
- MDN — <script>: The Script element
- MDN — Grammar and types
- MDN — Addition (+)
- MDN — Number() constructor
- MDN — parseInt()
- MDN — NaN
- MDN — String.prototype.normalize()
- MDN — for...of
- MDN — Functions
- Chrome for Developers — Console overview
- Wirfs-Brock & Eich — JavaScript: The First 20 Years (HOPL IV)
页面截图是笔者制作的演示页面,于2026年10月17日在云端的 Chromium(Playwright)中拍摄。控制台画面是把 console.log 的输出写到页面上的手工再现,并非真正的开发者工具画面。陷阱的式子和测试在 Chromium 141 与 Node.js 22 中运行,确认结果一致。script 位置的结果也是在同一个 Chromium 中查得的值。登场人物均为虚构。
古法前端入门 第7讲 — JavaScript ①:基础与合计函数 / 2026.10