Old-School Front-End / 2026 ・ No.07

古法前端入门

第7讲 JavaScript ① ── 基础:写一个计算明细合计的函数

发布

左边是 js/app.js 的代码,右边是控制台。代码里有 toAmount、calcTotal、needsReceipt、formatYen 这 4 个函数。控制台里是 12 项测试通过的标记,以及 calcTotal(items) 为 42240、formatYen 为 '42,240 日元'、needsReceipt 为 true
本讲的目标。①写 4 个只负责计算的函数,②用手写的测试来确认(笔者的演示页面,在云端的 Chromium 中截图。2026年10月17日。控制台画面是把 console.log 写到页面上的手工再现,并非真正的开发者工具。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。从第7讲开始,终于轮到 JavaScript 了。
如果 HTML 是骨架、CSS 是衣服,JavaScript 就是肌肉。它的第一份工作,是新井一直用计算器算的明细合计。
值的类型、变量、函数、循环,还有处理金额时一定会踩的“明明是加,却拼起来了”的坑。
这一讲还不碰页面(DOM),只写负责计算的函数,并自己来测试。

一

本讲的目标

新井前辈,我让 AI 写了合计的计算!……可是大阪出差的合计显示成“0147201280014720 日元”。

古井147 万亿日元的出差啊,财务要吓瘫了。

新井看起来只是把数字全拼在一起了……

古井没错。JavaScript 的“+”有两副面孔。今天就从这里讲起。

当前位置:第7讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾

这次要写的是 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 的位置与执行时机(实测)

从上往下读 HTML <script> in head 读完之前就执行 <head> … <body> <h1> … document.body → null <script defer> 读完之后才执行 <head> … <body> <h1> … document.body → body </body> 之前 最后执行 <head> … <body> <h1> … document.body → body 竖线是执行的时机。本讲在 head 里加 defer 来写

直接写在 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 的值有类型。把报销里会出现的列出来:

值的类型,以及报销中的例子

number 14720 数,可以做加减 例:金额 string '14720' 文字,用引号括起来 例:输入框的值一定是它 boolean true true 或 false 二选一 例:是否必须上传收据 null null 表示“故意为空” 例:金额栏是空的 undefined undefined “里面还什么都没有” 例:忘了赋值的变量 object { amount: 14720 } 带名字的值的集合 数组 [ ] 是它们的列表 同样是 14720,number 和 string 的表现完全不同

关键在于,同样是 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 万亿日元”的真相:

“+”是加,还是拼接?(实测)

14720 + 12800 → 27520 加法 数与数 '14720' + '12800' → '1472012800' 拼接 字符串与字符串 0 + '14720' → '014720' 拼接 只要有一个是字符串 '14720' - '12800' → 1920 却成了减法 - 会先转成数 只有 + 身兼“相加”和“拼接”两职。输入框的值是字符串,一定要先转成数再相加

“+”两边都是数时做加法,只要有一边是文字,就变成拼接。输入框的值是文字,直接相加的话……

控制台画面。把字符串数组 ['14720', '12800', '14720'] 用 for 直接相加,结果是字符串 '0147201280014720',typeof 为 'string'。先用 toAmount 转成数再相加,得 42240
AI 常写的 bug。把输入框的值(字符串)直接相加,不是“加”,而是“拼接”(笔者的演示页面,在云端的 Chromium 中截图。2026年10月17日。控制台画面是把 console.log 写到页面上的手工再现,并非真正的开发者工具。浏览器边框是为了说明而补画的)

即使合计从 0 开始,0 + '14720' 那一刻就成了文字 '014720',之后就一路拼下去。更麻烦的是,“-”会先把文字转成数再减,所以只有减法能正常工作。所以输入的值,在相加之前一定要转成数。这是本讲最重要的教训。

处理金额时还会遇到其他陷阱,我收集起来实际运行了一遍:

两列并排的控制台画面。字符串相加、Number('') 为 0、Number('1,000') 为 NaN、parseInt('1,000') 为 1、全角数字为 NaN、0.1 + 0.2 为 0.30000000000000004、NaN === NaN 为 false、typeof null 为 'object' 等 17 个式子和结果
处理金额时会遇到的陷阱一览。全部是在浏览器里实际运行的结果(笔者的演示页面,在云端的 Chromium 中截图。2026年10月17日。控制台画面是把 console.log 写到页面上的手工再现,并非真正的开发者工具。浏览器边框是为了说明而补画的)
# 在 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 ── 计算机有时无法精确表示小数。金钱用整数处理(日元就用日元,美元就用美分),这是铁律
六

函数与循环

给合计的计算起个名字,让它能反复使用,这就是函数。

函数的结构

function calcTotal(items) {   let total = 0;   for (const item of items) {     total += item.amount;   }   return total; } 名字 一看就知道做什么 参数 items 接收的值(明细数组) 主体 一行一行累加 return 返回结果,结束 calcTotal(items) 42240 调用 结果返回来 函数是“接收输入、返回结果”的盒子,不需要知道页面的事

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(运行结果)

次 item item.amount total 0 — — 0 1 新干线(东京→新大阪) 14720 14720 2 酒店 1 晚 12800 27520 3 新干线(新大阪→东京) 14720 42240 for (const item of items) 把数组的内容逐个放进 item,重复执行主体

新井AI 写的是 items.reduce((a, b) => a + b.amount, 0) 一行搞定。这样不是更帅吗?

古井做的是同一件事。不过以你现在的水平,能解释“为什么能运行”的是 for 吧?与其写一行说不清的,不如写五行说得清的。reduce 等熟练了再用也不迟。

七

把输入转成金额

为了避开“+”的坑,我们写一个把输入框的文字转成金额的函数 toAmount。用一个混着全角空格、全角数字和全角逗号的、很有现场感的输入试试:

toAmount 内部发生了什么(运行结果)

' 14,720 ' 含全角空格・数字・逗号 .normalize('NFKC') → ' 14,720 ' 全角变半角 .replaceAll(',', '') → ' 14720 ' 去掉逗号 .trim() → '14720' 去掉前后空白 /^[0-9]+$/.test(…) → true 确认只有数字 Number(…) → 14720 转成数(number) 要确认只有数字,是因为 Number('0x10') 得 16,Number('1e3') 得 1000

字符串自带 .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 和以上这三种。“正好”的处理,无论在需求文档还是代码里,都是最容易出错的地方。

POINT

请 AI 写代码时,建议连测试一起要,并且自己读这些测试。像“29,999 为 false,30,000 为 true”这样,测试比文字更精确地表达需求。如果测试和规定对不上,那是比代码更应该先改的“理解偏差”。开头那 147 万亿日元,只要有一行“合计是 42240”的测试,上线前就能抓住。

结 — 一页总结与下讲预告

  1. JavaScript 用 加了 defer 的 script 元素读入。试运行用开发者工具的控制台。
  2. 值有类型。输入框的值一定是文字(string),可以用 typeof 确认。
  3. 变量 先用 const,要替换的才用 let。不用 var。
  4. “+”遇到文字就拼接。输入的值相加前要转成数。注意空栏・全角・逗号・NaN・小数,金钱用整数。
  5. 计算写成与页面分离的函数,用包含分界的测试来确认。

新井42,240 日元,算出来了!……不过这只是在控制台里吧?页面上的合计栏还是空的。

古井下一讲是 DOM 与事件。一输入合计就变,一点按钮明细就多一行。页面终于要动起来了。

参考

页面截图是笔者制作的演示页面,于2026年10月17日在云端的 Chromium(Playwright)中拍摄。控制台画面是把 console.log 的输出写到页面上的手工再现,并非真正的开发者工具画面。陷阱的式子和测试在 Chromium 141 与 Node.js 22 中运行,确认结果一致。script 位置的结果也是在同一个 Chromium 中查得的值。登场人物均为虚构。

古法前端入门 第7讲 — JavaScript ①:基础与合计函数 / 2026.10

← 返回文章列表