第二章 · 让机器听懂你
JavaScript 基础:从「我想要」到「机器照做」
你已经见过 bot.js 里那些代码,能大概猜到它们在干什么;但要你自己从空白文件里写出一行,你完全不知道该从哪下手。这一章要填的就是这道缝——不是让你「认识」JavaScript,而是让你能亲手写下规则,然后看机器真的照着做。
0. 先看地图:这一章要带你去哪
读完这一章,你应该能回答
- 「程序」到底是什么东西?它为什么既听话又死板?我凭什么相信一行文字能命令一台机器?
- 怎么把一行代码真正跑起来?
console.log为什么是我最重要的工具,而它到底把东西打印到了哪里? const和let该用哪个?为什么"1" + 1是"11",而"1" - 1是0?event.sender?.card里那个问号在挡什么?去掉它会发生什么?- 面对一条 QQ 消息,为什么代码里写的不是「一句话」,而是一个「数组套对象」的结构?
- 怎么写一个能自己匹配指令、匹配关键词、判断 @、还有冷却的处理器?
- 报错
Cannot read properties of undefined的时候,我该怎么一步步找到那一行?
学完这一章,你会做这些事
- 读懂
bot.js里大部分代码在做什么 - 独立写出能跑的小程序:变量、条件、循环、函数、数组与对象
- 处理 OneBot 的消息段数组,把结构化消息变成一句纯文本
- 用字符串方法与正则做真实的文本清理
- 看懂报错堆栈,并定位到你自己写的那一行
需要的前置:第一章(看见机器:计算机常识与 Linux)里的「文件与路径」「进程」,以及你能在自己的电脑上打开一个终端、能敲 node -v 看到版本号。如果还没有 Node,去 nodejs.org 装一个 LTS 版本,装完回来。真的,现在就去,这一章后面每一节都要求你手边有一个能敲命令的窗口。
这一章会为后面铺路:第四章(消息如何跨越一千公里)会把这里出现的 JSON 结构完整讲透——你会发现今天当例子的那段消息,就是 OneBot 协议的真实格式;第五章(让程序住在云端)会把 await 和异步补上,那时你会发现这一章刻意避开的「等待」原来那么重要;第九章(走向远方)会教你给今天写的这些函数补上测试,让它们在你改坏的时候自己喊出来。
回头看看:序章里我们分了「现象层 / 机制层 / 原理层」,还立了七条心法。这一章是那份心法第一次真正被用上的地方——你会大量地「先跑通再理解」,也会第一次尝到「合上教程自己写」到底有多难。如果你还没读序章,先去读序章,尤其是第 5 节和第 6 节。这一章里我会反复用到「现象 / 机制」这两个词,它们不是修辞,是工作方法。
1. 编程到底是什么:一份写给死板执行者的说明书
先把你脑子里那个模糊的印象换成一句准确的话:
程序 = 数据 + 操作 + 顺序。
把要处理的东西(数据)摆好,选好每一步要对它做什么(操作),然后规定这些步骤谁先谁后(顺序)。没有任何一行代码能逃出这三样东西。你以后读到的所有复杂系统,拆到最后都是这三样。
1.1 先从一部电梯说起
假设你面前站着一个从没见过电梯的人。他力气很大、耳朵很好、绝不打折扣地照做,但他完全没有常识——你说什么他就做什么,你没说的他一件也不做。你要教他从一楼到五楼。
你可能会这么说:
- 走到那个有两扇金属门的墙前面;
- 伸出右手,按下右边那个朝上的三角形按钮;
- 等门打开;
- 门完全打开之后,迈进去;
- 在右侧面板上找到写着 5 的圆钮,按它;
- 等门再次打开;
- 走出去。
注意这几件事。第一,你不能说「上去五楼」——他不懂这个概念,他只会执行动作。第二,顺序不能乱:先迈进去再按按钮可以,先按按钮再找门就会出问题。第三,你漏掉任何一步他都会卡住:如果你没说第 4 步,他会站在门口不动,而且不会觉得奇怪,因为他的世界里没有「这不合常理」这种判断。
现在把这段话翻译成代码:
const 目标楼层 = 5; // 数据:我们要去哪
let 当前位置 = 1; // 数据:现在在哪
const 门开着 = false; // 数据:门的状态
按上行按钮(); // 操作
等门打开(); // 操作
迈进去(); // 操作
按楼层钮(目标楼层); // 操作,用了上面那个数据
等门打开(); // 操作
走出去(); // 操作
▲ 这不是能运行的 JavaScript(中文标识符虽然合法,但那些函数并不存在)。它只是把「说明书」这个比喻落在纸面上,让你看清代码的形状:一堆写着名字的盒子,加上一串有先后的动作。
真正的 JavaScript 会长成这样:
const targetFloor = 5;
let currentFloor = 1;
let doorOpen = false;
pressUpButton();
waitForDoor();
stepIn();
pressFloorButton(targetFloor);
waitForDoor();
stepOut();
console.log("到达", currentFloor); // 打印:到达 1
▲ 最后一行会打印「到达 1」,而不是「到达 5」。原因很简单:没有任何一行代码把 currentFloor 改成 5。机器不会因为你「心里想的是五楼」就自己更新。这正是新手最需要接受的现实:代码描述的是你写下来的意图,不是你以为的意图。
要点程序的执行者有两个特征,缺一不可:极其听话(你写什么它做什么,不会自作主张)和极其死板(你没写的它绝不做,也不会替你想「你是不是想改成 5」)。所有的 bug 都诞生在这两点之间:前者让你能指挥一台机器,后者让你必须把每一步都想清楚。
1.2 你以为编程靠数学,其实靠拆解
「我数学不好,所以学不会编程。」这句话是这条路上最大的一块假障碍,必须现在就搬走。
编程真正需要的是什么?是把一件你觉得「一句话就说完了」的事,拆成机器能执行的、没有歧义的步骤。这个能力叫拆解,和数学的关系很弱,和「表达是否精确」的关系极强。
看一个真实的例子。OWL 要做的其中一件事是:把一条 QQ 消息里 @ 她的那部分去掉,只留正文。你可能会说:「把 @ 去掉不就行了,这也叫问题?」
但机器不接受这句话。它要问的是一串具体的问题:
- @ 后面跟的是 QQ 号,QQ 号有多长?会不会有人名字里带 @?
- 去掉 @ 和 QQ 号之后,后面那个空格要不要一起删掉?
- 如果有人写的是
<@!12345>这种形式呢? - 如果有人 @ 了别人而不是她,要不要删?(这决定了你要不要先判断被 @ 的是谁)
- 如果一句话里 @ 了五个人呢?
这一串问题,没有一个需要你会解方程。它们需要的是「把一句话背后的所有情况列出来」——而这件事,你在生活里天天在做(比如跟室友约定谁洗碗的时候,你会本能地想到「那如果我那天不在呢」)。
真正和数学关系近的部分,是本课程后面才会出现的:复杂度、向量、相似度、概率采样。它们确实需要一点数学直觉,但等到你需要它们的时候,你已经有了足够的上下文去学那一小块数学。现在把「数学不好」当成不开始的理由,是在用未来某一章的难度,否定今天这一步。
想一想把你今天早上做的一件事(比如「出门买早餐」)写成一份给那种死板执行者的说明书。写完之后数一数:你用了多少步?其中哪一步是「你以为不用说、其实必须说」的?那个被你漏掉的步骤,就是你身上最缺的编程直觉所在的位置。
1.3 顺序:最容易被忽视的一半
数据、操作都容易理解,最容易被轻视的是「顺序」。而顺序恰恰是程序逻辑里最高频的 bug 来源。
看你已经见过的 bot.js 里的主流程(下面这段是简化版,只保留了骨架,原文见第 373–437 行):
// 1) 指令最优先,永远不被 AI 抢走
const cmd = matchCommand(event, text);
if (cmd) { await sendAndLog(cmd.reply); return; }
// 2) AI 接管
if (shouldUseLlm(event, text)) { ... return; }
// 3) 静态兜底:关键词 / @ / 私聊
const stat = decideStatic(event, text);
if (stat) { await sendAndLog(stat.reply); return; }
▲ 这三段代码本身就是一句被写下来的判断:「先看是不是指令,不是再看要不要给 AI,还不是才走关键词兜底。」如果把第 2 段和第 3 段对调,行不行?技术上能跑,但行为就变了:那时候一条命中关键词的消息会先被静态规则抢走,永远轮不到 AI。你能看出这一点,就说明你开始有「顺序感」了。
换一句话说:代码里的顺序不是排版,是语义。你调换两段的位置,等于换了一个程序。
1.4 立刻能用的三个习惯
在写第一行真正的代码之前,先接受三个习惯。它们比你记住任何语法都重要。
- 一次只加一样东西。加一段代码,跑一次,看结果对不对,再加下一段。新手最常犯的错是一次写三十行,然后对着一个不知道哪来的错误发呆。
- 让机器说话。不确定某个变量的值是什么,就把它打印出来。这不是「初级做法」,这是所有级别的开发者每天都在做的事。
- 把它写下来。刚才电梯那几个问题,如果只在你脑子里转,你会觉得「我懂了」;一旦要写成字,你会发现有三处你其实没想清楚。
2. 第一行代码的三种跑法
现在动手。你要先学会的不是语法,是怎么让一行代码真的被执行。这一节结束时,你的屏幕上应该已经出现过属于你自己的输出。
2.1 路径一:node 直接进入交互模式(REPL)
打开终端(Windows 用 PowerShell,macOS 用「终端」,或者直接用 VS Code 里的终端),敲一个词:
node
回车之后,你会看到提示符变成了一个 >。这表示 Node.js 已经进入了 REPL(Read-Eval-Print Loop,读取—求值—打印循环)模式:你敲一行,它立刻执行一行,并把结果打印给你,然后再等你敲下一行。
现在敲这一行:
1 + 1
你会看到 2。注意:你并没有写 console.log(1 + 1),它也打印了。这是 REPL 的特性——它会自动把每一行的「结果值」显示出来。而在一个真正的 .js 文件里,你不写 console.log,就什么都看不见。这个差别是新手第一个常见的困惑来源。
再试几个,观察它的反应:
"OWL" + " 你好"
typeof 42
Math.max(1, 5, 3)
退出 REPL 的方式是按住 Ctrl 再按两次 C,或者输入 .exit。
技巧REPL 是「一行算盘」:验证「这个写法到底是 0 还是 false」「这个方法的参数是正的还是负的」这类小问题,比你在文件里改来改去、保存、运行快十倍。高手和新手的区别之一,就是高手更愿意用 REPL 试一下,而新手更愿意猜。
2.2 路径二:写一个 .js 文件,用 node 跑它
REPL 只能试一行。真正的程序要写成文件。新建一个文件夹(比如叫 js-practice),在里面建一个文件 hello.js,写:
console.log("你好,OWL");
console.log(1 + 1);
然后在终端里,先进入那个文件夹,再执行:
cd js-practice
node hello.js
屏幕上会依次出现两行:你好,OWL 和 2。
这里有两个必须现在就懂的机制。
第一,node hello.js 不是你「打开」了这个文件,而是你让 Node.js 这个程序去读取它、解释它、执行它。这和你用 Word 打开一个文档完全不同:Word 会一直开着等你编辑,而 node 执行完最后一行就退出了,回到你的命令提示符。序章里说的「解释器」,指的就是这么一个东西。第一章讲过「进程」,你刚才就亲手启动了一个进程,它活了几毫秒,然后死掉了。
第二,路径必须是能找到的。如果你在别的目录里敲 node hello.js,会看到 Error: Cannot find module。这不是代码错了,是你没在它所在的目录里。这类「文件找不到」的错误,以后你在部署 OWL 时还会遇到很多次,我们会在第七章一起处理。
2.3 路径三:浏览器控制台
打开任意一个网页(比如你现在看的这个网站),按 F12,切到「控制台 / Console」标签。你会发现那里也能敲 JavaScript,而且是立刻执行。
浏览器控制台和 Node 的关系,值得现在就说清楚,因为它能省掉你未来的一次困惑:
| Node.js | 浏览器 | |
|---|---|---|
| 语言核心(变量、函数、数组、对象) | 一样 | 一样 |
能操作网页(document、window) | 不能,没有网页 | 能 |
能读写文件(fs 模块) | 能 | 不能,出于安全被禁止 |
| 能起一个服务监听端口 | 能 | 不能 |
▲ 关键点只有一句:它们共享同一套语言,但各自多带了一套「环境能力」。OWL 跑在 Node 里,所以她能读写 history.json、能监听 3001 端口;而网页里的 JavaScript 做不到这两件事。
2.4 console.log:你的第一只眼睛
console.log(...) 的作用是:把它括号里的东西,输出到「标准输出」(在终端里就是你眼前那一块屏幕)。它可以接任意多个参数,用空格隔开:
const name = "OWL";
const n = 3;
console.log("名字是", name, ",数量是", n);
// 输出:名字是 OWL ,数量是 3
你会在 bot.js 里到处看到它,而且它不是随便写的——它是那个项目里唯一的「眼睛」。看这一段真实的代码(bot.js 第 72–82 行):
function log(...args) {
const line = `[${stamp()}] ${args
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
.join(" ")}`;
console.log(line);
try {
fs.appendFileSync(LOG_PATH, line + "\n");
} catch {
/* 日志写不进去不影响运行 */
}
}
▲ 逐行读:function log(...args) 里的三个点是「把传进来的所有参数收成一个数组」,所以 log("a", 1) 之后 args 是 ["a", 1]。.map(...) 把每一项变成字符串:本来就是字符串的就留着,不是的(比如一个对象)就用 JSON.stringify 转成 JSON 文本——否则你打印对象会看到 [object Object] 这种毫无信息的东西。.join(" ") 用空格把它们拼成一行。然后 console.log(line) 打到屏幕,fs.appendFileSync 追加写到日志文件。最后那个空的 catch 是有意的:写日志失败不应该让机器人崩溃。
从这段话里你能看出 console.log 的两个层次:新手用它看变量的值;而一个成熟的项目用它构成「可观测性」——出问题时你能从日志里还原出当时发生了什么。第九章会专门讲这件事。
警告新手最常做的事,是遇到问题就把整段代码丢给 AI 说「它不对,帮我改」。请先做一件更便宜的事:在你怀疑的那几个位置各加一行 console.log,让程序自己告诉你它看到了什么。百分之八十的「不知道为什么」,答案是「那个变量的值根本不是你想象的那个」。
2.5 注释:写给你自己的便签
代码里以 // 开头的部分是注释,机器完全不看它。/* ... */ 可以跨多行。注释的唯一价值是「让三个月后的你或别人,不用重新推理这段代码在干嘛」。
写注释有一条判断标准:不要解释「这行在做什么」,要解释「为什么这么写」。比较 bot.js 里这两行真实的注释:
// 坏的注释(没人需要)
// 把 n 补成两位
const p = (n) => String(n).padStart(2, "0");
// 好的注释(这才是信息)
// 日志时间戳:用本地时间,不要用 toISOString()。
// 踩过的坑:早期用 toISOString() 输出的是 UTC,而服务器时区是 Asia/Shanghai,
// 于是日志比服务器实际时间慢 8 小时。排查故障时看日志会误判"最后活动时间",
// 浪费大量时间对着错误的时间线推理。
▲ 第二段注释之所以值得存在,是因为它记录了一个踩过的坑:它挡住的是未来某个想「顺手改成 toISOString 更标准」的人。代码告诉你「是什么」,注释告诉你「为什么不是别的样子」。
想一想上面那段注释里说「日志用 UTC 会慢 8 小时」。那么请你算一下:如果你在中国,用 toISOString() 打的日志在北京时间 6 月 1 日早上 7 点写下了「2025-05-31T23:00:00Z」,你看到这行日志时会误判成什么时间?这个误判会让你在排查「机器人是几点掉线的」时得出什么错误结论?
3. 变量与类型:给数据起名字
程序要处理数据,第一件事就是把数据存起来、并给它一个名字,方便后面反复使用。这件事叫「声明变量」。
3.1 const 和 let:默认用哪个
现代 JavaScript 里,声明变量基本只用两个词:
const botName = "OWL"; // 常量:这个名字之后不能再指向别的东西
let counter = 0; // 变量:之后可以改
counter = counter + 1; // 合法,counter 现在是 1
const x = 1;
x = 2; // ❌ TypeError: Assignment to constant variable.
还有第三个词 var,你在很多老教程里会看到它。不要用它。原因不是「它过时了」这种含糊的说法,而是它有两个具体的、会让你半夜找不到 bug 的行为:
- 它不受「块」限制。在
if或for里用var声明的变量,出了那个花括号还活着,于是两个不相干的地方可能悄悄用了同一个名字。 - 它可以在声明之前被访问,得到
undefined而不是报错。这意味着一个拼写错误不会立刻炸,而是安静地给你一个空值,等你半小时后在别的地方发现结果不对。
那 const 和 let 之间怎么选?给你一条可以直接照做的规则:
默认全部写 const。只有当这一行确实需要被重新赋值时,才改写 let。
理由不是「更安全」这么空。理由是:当你在读一段代码、看到 const 时,你的大脑可以立刻把「它的值会不会变」这个疑问关掉。一个文件里 let 越少,你需要同时记住的东西就越少。
这里有一个新手一定会踩的坑,现在先说破:const 锁住的是「这个名字指向谁」,而不是「它指向的东西内部不能变」。
const cfg = { cooldownMs: 1200 };
cfg.cooldownMs = 2000; // ✅ 完全合法!对象内部被改了
cfg = { cooldownMs: 3000 }; // ❌ 报错:不能重新指向另一个对象
cfg.keywords.push({ ... }); // ✅ 也合法,往数组里加东西不受 const 限制
▲ 所以你在 bot.js 里会看到 cfg.keywords ??= []、cfg.reply ??= {} 这样的写法——它修改对象内部,不需要把 cfg 改成 let。??= 的意思是「如果左边是 null 或 undefined,就把右边赋给它」。
3.2 六种你会天天遇到的类型
类型就是「这个数据是哪一类东西」。JavaScript 里最常打交道的是这六种:
| 类型 | 长什么样 | 它是什么 |
|---|---|---|
string 字符串 | "你好" 'OWL' `hi` | 一串文字。三种引号都行,反引号是模板字符串(下面讲) |
number | 42 3.14 -1 | 数字。不区分整数和小数,都是 number |
boolean 布尔值 | true false | 只有两个值。所有判断的最终结果都是它 |
null | null | 「这里故意没有值」。是有人主动放的 |
undefined | undefined | 「这里还没有值」。通常是系统给的 |
NaN | NaN | Not a Number,一个「不是数字的数字」。是算错时的产物 |
用 typeof 可以问一个值「你是什么类型」:
typeof "OWL" // "string"
typeof 42 // "number"
typeof true // "boolean"
typeof undefined // "undefined"
typeof null // "object" ← 这是 JavaScript 的历史 bug,不用记,知道有这回事就行
typeof NaN // "number" ← NaN 的类型确实是 number,别被名字骗了
最后两行值得停一下。typeof null 返回 "object" 是 1995 年 JavaScript 诞生时的实现失误,后来因为改动会破坏太多已有网页,就永久保留了下来。你会不断遇到这种「历史上搞错了、现在改不了」的东西。它们不是你的理解出了偏差,而是真实世界的技术债。第九章会讲怎么和它们相处。
3.3 模板字符串:把数据嵌进句子里
拼接字符串最老的办法是加号:
const name = "小明";
const n = 3;
console.log("你好 " + name + ",你有 " + n + " 条新消息");
能跑,但一旦要拼的东西多了,引号和加号会糊成一片。模板字符串(反引号)解决的就是这件事——用 ${} 把任意表达式直接塞进文本里:
const name = "小明";
const n = 3;
console.log(`你好 ${name},你有 ${n} 条新消息`);
console.log(`两倍的数量是 ${n * 2}`); // 里面可以放任何表达式
console.log(`当前时间 ${new Date().getHours()} 点`);
▲ 反引号的另一个能力是可以换行,普通引号不行。所以 bot.js 里的危机求助文案是这么写的(第 91–96 行,简化了缩进):
const CRISIS_RESOURCE =
"再明确说一次可以找谁:\n" +
"· 12356 —— 全国心理援助热线,24 小时免费,不用怕被评判\n" +
...;
▲ 这里用的是普通引号 + \n(换行符)。如果用反引号,可以写成真正的多行文本,读起来更接近最终显示的样子。两种写法都对,但你要能认出 \n 就是「换行」——你在日志里看到的 / 替代换行(bot.js 第 368 行的 .replace(/\n/g, " / "))就是在处理它。
3.4 坑一:"1" + 1 是 "11",而 "1" - 1 是 0
这是 JavaScript 最著名的行为之一。先去 REPL 里亲手打一遍:
"1" + 1 // "11"
"1" - 1 // 0
"1" * "2" // 2
"abc" - 1 // NaN
true + 1 // 2
[] + [] // "" (两个空数组相加得到空字符串)
1 + 1 + "1" // "21" (从左往右算:1+1=2,然后 2+"1"="21")
"1" + 1 + 1 // "111" (先 "1"+1="11",再 "11"+1="111")
为什么会这样?因为 + 在 JavaScript 里是一个兼职运算符:它既要管加法,又要管字符串拼接。当它发现两边有一个是字符串时,它选择做拼接,于是把另一边也偷偷转成字符串。而 -、*、/ 没有兼职,它们只能做数学运算,所以会努力把两边都转成数字——转不动就给你 NaN。
这个行为有一个非常现实的后果。想想这段代码:
function add(a, b) {
return a + b;
}
add(1, 2); // 3 ✅
add("1", "2"); // "12" ❌ 但它不报错!
▲ 危险的地方不是结果错了,而是它不报错。一个安静给出错误答案的程序,比一个当场崩溃的程序难查一百倍。第一种错误你十分钟能修,第二种可能让你在两个月后才发现库存数量对不上。
警告线上事故里有一整类叫「隐式类型转换」:金额从表单里读出来是字符串,于是 "19.9" + 0.1 不是 20,而是 "19.90.1"——加号发现左边是字符串,就改做拼接了。另一类是 ID 被当成数字,于是 007 变成了 7,用户查不到自己的订单。把「数据是从哪来的、它到底是什么类型」当成一个必须确认的问题,是写代码的基本卫生。
注意别把两件事混起来。"19.9" + 0.1 === "19.90.1" 是类型转换问题(加号兼职拼接),而 0.1 + 0.2 !== 0.3 是浮点精度问题(二进制表示不了 0.1 和 0.2)——后者在所有语言里都一样,跟类型毫无关系。它们的现象都是「数字算出来不对」,但成因和修法完全不同:前者靠统一类型解决,后者要靠「换算成整数再算」或者接受一个小误差范围。
3.5 坑二:为什么一律用 ===
JavaScript 有两个相等运算符:==(宽松相等)和 ===(严格相等)。区别是前者会先做类型转换再比较:
"1" == 1 // true ← 因为 "1" 被转成了 1
"1" === 1 // false ← 类型不同,直接判不等
0 == "" // true ← 两者都被转成数字 0
0 == false // true
null == undefined // true ← 这一条是 == 唯一「有用」的地方
null === undefined // false
NaN === NaN // false ← 连自己都不等于自己!
== 的转换规则有一张很长的表,长到几乎没有人能完全记住。一份你记不住的规则,就不该出现在你的代码里。所以给你一条硬规则:
一律用 === 和 !==。不要用 == 和 !=。
唯一例外是 x == null(不写 ===)——它同时命中 null 和 undefined,这在「这两个都算没值」的场景里非常方便。除此之外,没有例外。
至于 NaN:因为它不等于任何东西(包括自己),判断它要用专门的方法:
const x = Number("abc");
x === NaN // false,永远 false,这个写法是错的
Number.isNaN(x) // true ✅ 正确写法
3.6 坑三:OneBot 传来的 user_id 到底是什么类型
现在把前面这些理论接回到你的项目上。请打开 bot.js,搜索 String(event.user_id)。你会看到它出现得很频繁:
// bot.js 第 237 行
const senderName = event.sender?.card || event.sender?.nickname || String(event.user_id);
// bot.js 第 243 行
userId: String(event.user_id ?? ""),
// bot.js 第 383 行
llm.clearMemory(String(event.user_id));
// bot.js 第 395 行
const r = await llm.chat({ sessionKey, userKey: String(event.user_id), ... });
为什么到处都要 String(...) 包一层?三个层次的原因,一层比一层重要。
第一层:QQ 号不是数字,是「编号」。数字是用来做运算的(加减乘除、比大小)。而 QQ 号你永远不会拿它去做运算——你不会说「他的 QQ 号比我大 1000」。它只是一串用来唯一标识一个人的字符。凡是拿来标识的东西,字符串才是正确的类型。电话号码、身份证号、订单号都是同一类:看起来像数字,其实是字符串。(想一想:手机号 013800138000 存成数字会怎样?开头的 0 没了。)
第二层:一旦它是数字,某些 QQ 号会出事。JavaScript 里的 number 是双精度浮点数,能精确表示的整数上限是 253 减 1,也就是 9007199254740991。QQ 号目前还没到那个长度,所以这一条今天不会咬你——但你会遇到另一件更现实的事:不同来源给的类型可能不一样。OneBot 规范里 user_id 是数字,但你自己的 config.json 里写的是 "botQQ": "1876148307"(字符串)。当一处是数字、一处是字符串时,用 === 比较的结果永远是 false。
第三层:字符串还有一个额外好处——它永远不会是 undefined 砸进你脸上。String(event.user_id ?? "") 的意思是:如果 user_id 是 null 或 undefined,就用空字符串代替,然后转成字符串。为什么要多写那个 ?? ""?因为 String(undefined) 得到的是字符串 "undefined"——一个看起来很像正常值的垃圾值。它会被当成用户名写进记忆文件,然后就永远待在那里,没有任何报错提醒你。写成 ?? "" 之后,最坏情况是得到一个空字符串,一眼就能看出不对。
要点这段代码给了你一个可以立刻复制的模式:在系统的边界上做归一化。数据从外面进来的时候(网络、文件、用户输入),不要相信它的类型;在入口处一次性统一成你要的类型,往后的代码就都可以假设「它一定是我要的样子」。这条思路在第四章讲协议、第九章讲输入校验时会再出现两次。
3.7 undefined 与 null,以及那个你一定会遇到的报错
这两个词都表示「没有值」,但来源不同:
undefined | null | |
|---|---|---|
| 谁给的 | 系统给的(自动) | 人给的(主动写的) |
| 什么时候出现 | 变量声明了没赋值;对象里没有这个属性;函数没有 return | 你明确写 return null,或明确把某个字段设为「空」 |
| 含义 | 「还没有值」 | 「有,就是我故意设成没有」 |
let a; // a 是 undefined
const o = { x: 1 };
o.y // undefined ← 对象里没有 y 这个键
function f() {}
f(); // undefined ← 函数没有 return,调用结果就是 undefined
function g() { return null; }
g(); // null ← 明确返回「没有」
为什么这个区别值得单独讲?因为你接下来三个月里见得最多的报错,就诞生在这里:
const event = { user_id: 10001 };
console.log(event.sender.nickname);
// ❌ TypeError: Cannot read properties of undefined (reading 'nickname')
这个报错要这样读:「你试图从一个 undefined 上读 nickname。」翻译成人话:读到 .nickname 的时候,它左边的那个东西(也就是 event.sender)是 undefined。之所以是 undefined,是因为这个 event 对象里根本没有 sender 这个键——取一个不存在的键,得到的永远是 undefined。
而 bot.js 里针对这种情况已经有了固定的写法(第 237 行):
const senderName = event.sender?.card || event.sender?.nickname || String(event.user_id);
▲ 这一行里有三个符号在替你挡刀:?.(可选链)、||(或)、??(空值合并)。它们各自解决什么问题,我们放在下一节一次讲清。
最后给你一个「没有类型的东西也能有名字」的对照,让你记住 undefined 和 null 在真实数据里的样子——这是 config.json 里的一条关键词规则:
{
"match": "你好",
"mode": "contains",
"reply": "你好呀,我是{botName}~"
}
▲ 这条规则里没有 null,也没有 undefined。如果你读到的规则却写成 {"match": "你好", "mode": null},那么后面判断 k.mode === "exact"、k.mode === "regex" 都会是 false,于是它悄悄走进 clean.includes(needle) 那一条分支。一个 null 就这样安静地改变了行为——这就是为什么我们在下一节要学 ??。
4. 运算符与表达式:把值算成另一个值
表达式就是「一段能算出一个值的代码」。1 + 1 是表达式,name === "OWL" 是表达式,event.sender?.card 也是。运算符是表达式里的动词。你已经见过算术和比较,这一节把剩下三类补齐:逻辑、可选链、三元。
4.1 算术与比较:从「算数」到「判断」
| 类别 | 运算符 | 要注意的 |
|---|---|---|
| 算术 | + - * / % ** | % 是取余数(7 % 3 得 1),** 是乘方。+ 有兼职问题(上一节) |
| 比较 | > < >= <= | 结果一定是 boolean |
| 相等 | === !== | 只用这两个,理由见上一节 |
| 逻辑 | && || ! ?? | 它们不一定返回 boolean,见下 |
关于数字,有两件事必须提前知道,否则你以后一定会遇到「算出来不对」的时刻:
0.1 + 0.2 // 0.30000000000000004 ← 不是 0.3
1 / 0 // Infinity
-1 / 0 // -Infinity
Number.MAX_SAFE_INTEGER // 9007199254740991
▲ 第一行不是 JavaScript 的 bug,是「用二进制小数表示十进制小数」这件事本身的限制,几乎所有语言都一样。它对你的实际影响是:永远不要用 === 去比较两个算出来的小数。要比较金额,就把它换成「分」用整数算;要比较小数,就看它们的差是否小于某个极小的值。第二行说明除法不会报错——1/0 得到 Infinity 而不是崩溃,于是「除以零」这个 bug 会安静地传播下去。
4.2 真与假:&& 和 || 到底返回什么
先把「哪些值算假」这件事定下来。JavaScript 里只有七种值是假(falsy):
false 0 -0 ""(空字符串) null undefined NaN
▲ 除了这七个,其他一切都是真。特别注意三个反直觉的:"0"(非空字符串)是真,"false" 是真,[](空数组)也是真,{} 也是真。「空的东西为假」这个直觉在数组和对象上不成立,必须死记。
然后是 && 和 || 的真面目:它们不返回 true/false,而是返回其中某个操作数本身。
"OWL" || "默认名字" // "OWL" ← 左边为真,直接返回左边
"" || "默认名字" // "默认名字" ← 左边为假,返回右边
0 || 100 // 100
"小明" && "去吃饭" // "去吃饭" ← 左边为真,返回右边
"" && "去吃饭" // "" ← 左边为假,直接返回左边(右边根本不会算)
这个行为有个名字叫「短路」:&& 一旦发现左边是假,右边的表达式根本不执行;|| 一旦发现左边是真,右边也不执行。这不只是省时间——它经常被用来当「守卫」:
// 只有当 user 存在时,才去读 user.name,否则整行直接短路成 undefined
const name = user && user.name;
&& 和 || 的「返回操作数」特性,正是 bot.js 里那一行能工作的原因:
const senderName = event.sender?.card || event.sender?.nickname || String(event.user_id);
▲ 读法是从左到右找第一个「真」值:群名片有值就用群名片;群名片是空字符串("" 是假)就试昵称;昵称也没有,最后落到 QQ 号。而 String(...) 的结果一定非空(就算内容是 "undefined" 也不为空),所以它一定兜得住。这叫降级链:一层一层退到最后一个「一定可用」的方案。
4.3 ??:|| 的精确版本
上一节的降级链看起来没问题,但它藏着一个隐患:|| 会把所有假值都当「没有」,包括 "" 和 0。
const cfgA = { maxKeywordReplies: 0 };
const a = cfgA.maxKeywordReplies || 2; // 2 ❌ 用户明明写了 0,被当成「没写」了
const b = cfgA.maxKeywordReplies ?? 2; // 0 ✅ 只有 null/undefined 才用默认值
这就是空值合并运算符 ??(nullish coalescing,空值合并)存在的全部理由:它只在左边是 null 或 undefined 时才用右边的值;0、""、false 都算「有值」,会被保留。
0 ?? 100 // 0
"" ?? "默认" // ""
false ?? true // false
null ?? "默认" // "默认"
undefined ?? "默认" // "默认"
现在回头看 bot.js 的配置加载,你会发现作者是刻意混用这两个符号的。这不是随手写的(以下是节选,原文在第 34–52 行):
cfg.keywords ??= []; // 用 ??=:只有「没有这个键」才补空数组
cfg.cooldownMs ??= 1500; // 用 ??=:就算显式写了 0 也尊重你(0 是合法的「不冷却」)
cfg.prefix ??= "/"; // 用 ??=:prefix 允许是 ""(表示指令不加前缀)
▲ 如果这里写成 cfg.cooldownMs ||= 1500,那么一个想「关掉冷却」的人把值设成 0 之后,会被悄悄改成 1500——他会以为配置不生效,然后花一小时查为什么。这就是「一个符号选错,制造一个半夜查不通的 bug」的最小样本。
警告?? 和 &&、|| 不能不加括号地混用,这是语法层面的硬规定:a ?? b || c 会直接报 SyntaxError。为什么语言要禁止它?因为这两种运算符的优先级关系没人能记住,写出来的人自己都会读错。想混用就加括号:a ?? (b || c)。这条规则的存在本身就是一课:语言设计者也知道「记不住的规则不该用」,这正是我们前面定下「一律用 ===」的同一条原则。
4.4 可选链 ?.:event.sender?.card 在挡什么
回到那个一定会遇到的报错。假设你收到一条消息事件,想读发送者的群名片:
const card = event.sender.card; // ❌ 如果 event 里没有 sender,这里就崩
?.(可选链,optional chaining)做的事是:如果点号左边是 null 或 undefined,就不要继续往后读,整个表达式直接得到 undefined,不报错。
const card1 = event.sender?.card;
// event.sender 存在 → 正常取 card
// event.sender 是 undefined → 整个表达式是 undefined,不报错
// 可以连用
const a = data?.foo?.bar?.baz;
// 也可以用在方法调用上:如果这个方法不存在,就不调用
const upper = text?.toUpperCase?.();
▲ 注意最后一行有两个 ?.:第一个挡住 text 为空,第二个挡住 toUpperCase 这个方法本身不存在。?. 挡的是「左边能不能继续点」,不是「结果对不对」。
现在把这个知识接回真实代码。bot.js 的 decideStatic 里有一串判断,如果你把里面的 ?. 全部去掉,会有多少种崩法?我们逐个数:
// 原文(简化缩进),bot.js 第 106–111 行
function shouldSendCrisisResource(level) {
if (cfg.llm?.safety?.crisisReply === false) return false;
if (!level) return false;
const min = cfg.llm?.safety?.crisisReplyMinLevel ?? "high";
return (CRISIS_RANK[level] ?? 0) >= (CRISIS_RANK[min] ?? 2);
}
这里有三处防护,每一处都在挡一种具体的现实:
cfg.llm?.safety?.crisisReply——config.json里可能完全没有safety这一段。没有?.的话,读cfg.llm.safety.crisisReply会直接抛Cannot read properties of undefined (reading 'crisisReply'),机器人当场挂掉。有?.的话,结果是undefined,而undefined === false是false,于是检查被跳过,程序继续走。cfg.llm?.safety?.crisisReplyMinLevel ?? "high"——如果配置里没写这一项,就用默认门槛"high"。注意这里为什么用??而不是||?因为值只可能是字符串,两者今天行为一样。但作者选了语义更准确的??:他要表达的是「没配置就用默认」,不是「空字符串就用默认」。(CRISIS_RANK[level] ?? 0) >= (CRISIS_RANK[min] ?? 2)——这是在读一张查找表{ mid: 1, high: 2, critical: 3 }。如果level是个表里没有的值(比如模型返回了"low",或者写错了变成"High"),CRISIS_RANK[level]会是undefined;用?? 0兜成 0,那么0 >= 2是假,于是不会补发求助渠道。
第 3 条读到这里,你应该停下来问一个问题——这是一个真实的、值得想的工程判断:
想一想第 3 条兜底的方向是「读不到等级时,不补发危机资源」。但这是危机场景:如果模型返回的等级拼写错了(比如 "HIGH" 而不是 "high"),这个函数会安静地返回 false,一个需要求助渠道的人就拿不到那条热线。那么:这个兜底该往「宁可不发」还是「宁愿多发」偏?如果改成 ?? 9(读不到就当最高等级),会带来什么新问题?把这两个方向的代价各写一句话,你就完成了一次真正的工程权衡练习。这个问题没有标准答案,但方向选错的代价是不对称的。
4.5 三元表达式:一行里的小分叉
当你要根据一个条件在两个值里挑一个时,可以写 if,也可以用三元表达式:
条件 ? 条件为真时的值 : 条件为假时的值
const greeting = isGroup ? "大家好" : "你好";
const label = count > 99 ? "99+" : String(count);
它和 if 的区别是:三元是「表达式」,它有值;if 是「语句」,它没有值。所以三元能直接放在赋值、函数参数、模板字符串里,if 不能:
// ✅ 三元:直接产出值
const prefix = isGroup ? `群 ${groupId}` : `私聊 ${userId}`;
// ⚠️ if 做不到这个,只能写成这样
let prefix;
if (isGroup) prefix = `群 ${groupId}`;
else prefix = `私聊 ${userId}`;
这条「表达式比语句更值钱」的规律你以后会反复遇到:能用表达式表达的地方,代码更短、更难写错。但它有一个必须守住的边界:
三元只用来「二选一取值」。一旦嵌套超过一层,就改用 if 或拆成函数。
因为嵌套三元(a ? b : c ? d : e)连写它的人第二天都会读错。代码的可读性比省下的三行更重要——这句话现在看起来像老生常谈,等你第九章回头读自己这一章写的代码时,你会真心同意。
顺便说,decideStatic 里那个嵌套三元其实是本章里最该被重构的一处(bot.js 第 280–286 行):
const matched =
k.mode === "exact"
? clean === needle
: k.mode === "regex"
? new RegExp(needle, "i").test(clean)
: clean.includes(needle);
▲ 它能读,因为三种模式刚好构成一个阶梯。但如果以后要加第四种模式(比如「前缀匹配」),这个结构就会开始变形。更耐改的写法是把它抽成一个函数,用 if 提前返回——if 版的写法我们在下一节亲手写一遍。
5. 条件与分支:让代码走出不同的路
到这里,你已经有了「判断」的能力(表达式能算出 true 或 false)。这一节学的是用判断来决定走哪条路。
5.1 if / else if / else 的形状
if (条件1) {
// 条件1 为真时执行
} else if (条件2) {
// 条件1 为假、条件2 为真时执行
} else {
// 都不为真时执行
}
三条务必记住的规则:
- 它从上往下检查,第一个为真的分支执行完就整体结束,后面的分支连条件都不会算。所以「顺序」在这里又一次变成语义。
else不是必须的。很多情况下不写else才是对的选择(见下一小节)。- 花括号不是装饰。即使分支里只有一行,也建议保留。省掉花括号的写法在以后插入第二行时极易出错,而且这类 bug 从来不会报错,只会让程序行为突然变了。
5.2 提前返回:为什么它比层层嵌套好
新手写条件时最常见的形状是「金字塔」:一层套一层,越往右缩进越深。用 shouldUseLlm 举例。这个函数要回答的问题是:这条消息该不该交给 AI 处理?它的真实实现是这样的(bot.js 第 316–333 行,简化了缩进):
function shouldUseLlm(event, text) {
const llmCfg = cfg.llm ?? {};
if (!llmCfg.enable) return false; // AI 关着,不接管
if (!llm.ready) return false; // 缺 Key 或配置不全,不接管
const t = llmCfg.trigger ?? {};
const isGroup = event.message_type === "group";
const clean = stripAt(text);
if (isGroup) {
if (t.at && isAtBot(event)) return true; // 群里 @ 了她
if (t.keyword) {
const list = t.keywordList ?? [];
if (list.some((k) => clean.includes(String(k)))) return true;
}
return false; // 群聊里其他情况都不接管
}
return Boolean(t.private); // 私聊:看配置允不允许
}
注意它的写法:所有的「不行」都在最前面用 return false 打掉,一路排除到底,最后剩下的就是「行」。这叫提前返回(early return)。它和「金字塔」版本是等价的,但读起来完全不同。对比一下:
// ❌ 金字塔版:要读到最里面才知道结论,而且每一层都要记住前提
function shouldUseLlm(event, text) {
const llmCfg = cfg.llm ?? {};
if (llmCfg.enable) {
if (llm.ready) {
const t = llmCfg.trigger ?? {};
const isGroup = event.message_type === "group";
const clean = stripAt(text);
if (isGroup) {
if (t.at && isAtBot(event)) {
return true;
} else if (t.keyword) {
const list = t.keywordList ?? [];
if (list.some((k) => clean.includes(String(k)))) return true;
}
return false;
} else {
return Boolean(t.private);
}
}
return false;
}
return false;
}
▲ 两段代码的行为几乎一样,但第二段有三个实际代价:(1)缩进到了第 15 列,你的眼睛要一路向右找;(2)你要在脑子里同时记住「enable 是真的、ready 是真的」这些前提;(3)加一条新规则时,你得决定插在哪一层里——而第一段只要在顶部再加一行「什么情况下不接管」就行。第三种代价最贵,因为它会随着时间累积。
要点提前返回的本质是:把函数写成一张「排除清单」。先写清所有不成立的情况,剩下的自然成立。它有两个额外好处:一是每一个 return 都可以带上一句注释解释原因(就像上面的代码那样);二是当你要插入一条新规则时,你永远只需要动顶部一处。
5.3 用真实结构看「分层判断」
把 shouldSendCrisisResource(上一节读过)和 shouldUseLlm 放在一起看,你会发现它们共享同一种结构,而这种结构就是你在 onEvent 里看到的那个三层调度的缩影:
| 层 | 它回答的问题 | 判断成本 | 失败时的后果 |
|---|---|---|---|
| 指令层 | 是不是 /ping 这类写死的命令? | 极低(字符串比较) | 几乎不可能出错 |
| 开关层 | AI 开着吗?密钥可用吗? | 极低(读布尔值) | 降级到静态规则,仍能回复 |
| 触发层 | 这条消息命中触发条件了吗? | 低(读消息段) | 不交给 AI,走关键词兜底 |
| 模型层 | 请模型生成回复 | 极高(一秒、要花钱、可能失败) | 要靠降级提示与危机兜底兜住 |
▲ 这张表有一个非常重要的读法:越便宜、越确定的判断,越要放在前面。指令判断几乎不可能失败,所以放在最前,永远不被后面抢走;模型调用最贵最不稳,所以放在最后,并且外面套着好几层降级。你在设计任何处理流程时都可以问自己这一句:「我是不是把一个又贵又不可靠的步骤,放在了又便宜又可靠的步骤前面?」
这也是为什么 onEvent 里有一行注释写着「指令最优先,永远不被 AI 抢走」。那不是一句口号,它对应一个具体的取舍:如果让人工智能来决定 /重置 是不是重置,那么某一天模型心情不好回了一句「我暂时不想忘记你」,用户就会以为自己的数据没删掉——而一个能被随机性影响的「删除」按钮,等于没有这个按钮。这个原则我们在第六章讲记忆与隐私时会正式用到。
5.4 switch:什么时候它值得用
switch 是多重分支的另一种写法。你在 segmentsToText 里见过它(bot.js 第 132–153 行):
switch (s.type) {
case "text":
return s.data?.text ?? "";
case "image":
return "[图片]";
case "face":
return "[表情]";
case "at":
return `@${s.data?.qq ?? ""}`;
case "reply":
return "";
default:
return `[${s.type}]`;
}
它和 if / else if 的区别,只有两条:
switch用===比较同一个值的一串候选,读起来更像一张表。- 它默认会「穿透」:一个
case执行完不会自动停,会继续往下执行下一个case,除非你写break或return。上面这段刚好每个分支都有return,所以不需要break。
穿透这个特性是 JavaScript 里最容易写出意外的地方之一。给你一个判断标准:
什么时候用 switch:你在对同一个值做三种以上并列的等值判断,而且每个分支都立刻返回或立刻 break。这时候 switch 比一串 else if 更像一张配置表,改动时也更安全。
什么时候不用:判断条件是「> 比较」「复杂的 && 组合」的时候(switch 只做相等比较);或者分支少于三个的时候(那时候 if 更直白)。
顺便解释一个你可能已经好奇过的细节:segmentsToText 的 default 分支返回的是 [${s.type}]。为什么要给一个「我不知道这是什么类型」的兜底?因为 OneBot 的消息段类型远不止这五种(还有 video、json、mface、forward 等等,第四章会看到完整列表)。如果 switch 没有 default,遇到未知类型它会返回 undefined,而 undefined 混进 .map() 的结果里会变成字符串 "undefined",直接污染整条消息。这就是「对未知输入要有一个明确的兜底,而不是让它自然掉下去」的一个真实样本。
想一想假设你的 switch 里 case "reply" 那一支你忘了写 return "",只写了 case "reply": 就接下一个分支。请你说出:如果收到一个 reply 段,函数会返回什么?再想一步:这个 bug 会不会报错?如果不会,你是在什么时候、通过什么现象发现它的?(提示:想想用户引用了一条消息再 @ 机器人时,屏幕上会出现什么。)
5.5 把嵌套三元改写成函数
上一节留了一个练习。我们把那段嵌套三元改写成带提前返回的函数,你对比一下两种写法的可扩展性:
/** 判断一条关键词规则是否命中。返回 boolean。 */
function keywordMatched(rule, clean) {
const needle = String(rule.match);
if (rule.mode === "exact") return clean === needle;
if (rule.mode === "regex") return new RegExp(needle, "i").test(clean);
// 兜底:mode 缺失、写错、或者写的是 "contains",都按包含匹配处理
return clean.includes(needle);
}
▲ 这段代码有三个地方比原来的嵌套三元更好:(1)加了参数名,读的人不用猜 k 和 clean 是什么;(2)最后那条分支有注释说明「为什么这里能兜底」;(3)以后要加第四种模式,只要再插一个 if。把一段藏在表达式里的逻辑抽成一个有名字的函数,这个动作有一个正式的名字,叫「提取函数」,它是世界上最便宜、收益最高的重构。第九章会再讲一遍它,那时我们会给它配上测试。
6. 循环与迭代:不要重复写第二遍
程序最擅长的事情之一是「把同一件事做很多遍」。让它重复的手段叫循环。
6.1 三种循环,各管一段场景
// 1) 经典 for:你关心「第几次」的时候用
for (let i = 0; i < 3; i++) {
console.log("第", i, "次");
}
// 输出:第 0 次 / 第 1 次 / 第 2 次
// 2) for...of:你只关心「每一个元素」的时候用(最常用)
const names = ["小明", "阿泽", "小雨"];
for (const name of names) {
console.log(name);
}
// 3) while:你不知道要转多少圈,只知道「转到某个条件不成立为止」
let n = 1;
while (n < 100) {
n = n * 2;
}
console.log(n); // 128 —— 循环里 n 依次是 2 4 8 16 32 64 128
for (let i = 0; i < 3; i++) 这三个部分分别是:起点(let i = 0)、继续的条件(i < 3,每次进入循环体前检查一次)、每一圈之后做什么(i++,也就是 i = i + 1)。i 从 0 开始、条件是「小于」而不是「小于等于」,这个搭配不是随便定的——它是为了配合「数组下标从 0 开始」这件事。看:
const names = ["小明", "阿泽", "小雨"];
// 下标: 0 1 2
// 长度是 3,最后一个下标是 2 = 长度 - 1
for (let i = 0; i < names.length; i++) {
console.log(names[i]);
}
▲ 如果你写成 i <= names.length,最后一圈会去读 names[3],得到 undefined;如果对它就调用方法,就是那个 Cannot read properties of undefined。这个「差一」错误(off-by-one)是编程史上最经典的 bug 类型,几乎每个人都要亲手犯过才记得住。
那为什么说 for...of 最常用?因为它不需要你管下标。上面那两个 for 做的事完全一样,但 for...of 少了三个可以写错的地方(起点、边界、自增)。能不用下标就不用下标——这是你在这一节最该带走的一条直觉。它和「能不用 let 就不用 let」是同一种思维方式:减少活动零件的数量。
警告while 是三种里最容易写出「死循环」的(条件永远为真,程序卡住,CPU 拉满)。给你一条纪律:写 while 的时候,先确认循环体里有一行代码在推动条件变化(上面例子里的 n = n * 2)。如果你在服务器上跑出一个死循环,它会占满 CPU,让 OWL 卡住,第七章的监控和 systemd 会帮你杀掉它——但那种杀是粗暴的,你会在日志里看到一次意外的重启。
6.2 什么时候该用 map / filter / find / reduce
手写 for 循环能做任何事,但大多数时候你想做的事只有四种。JavaScript 给这四种情况各配了一个方法,统称数组的迭代方法:
| 方法 | 它问的问题 | 返回 | 例子 |
|---|---|---|---|
map | 「每一项变成别的样子」 | 一个长度相同的新数组 | [1,2,3].map(n => n * 2) → [2,4,6] |
filter | 「哪几项要留下」 | 一个可能更短的新数组 | [1,2,3,4].filter(n => n % 2 === 0) → [2,4] |
find | 「第一项符合条件的是谁」 | 那一个元素,或 undefined | [1,2,3].find(n => n > 1) → 2 |
reduce | 「把所有项汇总成一个东西」 | 任意类型的单个值 | 求和、计数、分组、拼字符串 |
它们有一个共同点:都接收一个函数当参数,那个函数负责「对每一项做什么」。这种「把函数当参数传进去」的用法,叫回调函数。bot.js 里遍布这种写法,其中最短的一段是(第 73–75 行):
args
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
.join(" ")
▲ 括号里的箭头函数就是回调:map 会拿着 args 里的每一项,轮流交给这个函数,把返回的结果收集成一个新数组。注意这里连 return 都没写——箭头函数在只有一行、且那一行是表达式时,可以省略 return 和花括号,这叫简写返回。
什么时候该把手写循环改写成这些方法?给你一条可以直接判断的标准:
如果你在循环体里做的第一件事是 push 到一个新数组,那你多半在写 map 或 filter。
如果你在循环里维护一个累加变量(total += ...),那你多半在写 reduce。
如果你只是要「做点什么」(打印、发消息、写文件),而不是要「得到一个新的值」,那循环就是对的形态。别为了显得函数式而硬套 map——map 会造一个你根本不要的数组,那是浪费。
6.3 一个真实任务:从配置里挑出所有正则规则
现在给你一个真实的、你以后真的会想做的任务:config.json 的 keywords 里混着三种模式(contains、exact、regex),你想把其中所有 mode === "regex" 的规则挑出来,检查它们的正则写得对不对。真实配置里的四条规则是(config.json):
"keywords": [
{ "match": "你好", "mode": "contains", "reply": "你好呀,我是{botName}~" },
{ "match": "帮助", "mode": "exact", "reply": "可用指令:/ping /time /help" },
{ "match": "测试", "mode": "contains", "reply": "收到测试消息,链路正常 ✅" },
{ "match": "谢谢", "mode": "contains", "reply": "不客气~" }
]
你会注意到:现在这四条里一条 regex 都没有。这是真实情况——这个机器人今天不需要正则。那我们为什么还要写这个筛选?两个理由:一是你以后一定会加;二是这段代码本身就是「用 filter 取代手写循环」最干净的练习。先看直觉版(手写循环):
const regexRules = [];
for (let i = 0; i < cfg.keywords.length; i++) {
const k = cfg.keywords[i];
if (k.mode === "regex") {
regexRules.push(k);
}
}
再看 filter 版:
const regexRules = cfg.keywords.filter((k) => k.mode === "regex");
▲ 三行变一行。但真正重要的不是行数,而是你能一眼看出这段代码在干什么:它是一句「从 keywords 里筛出 mode 是 regex 的」,没有下标、没有 push、没有循环变量需要你在脑子里跟踪。缩进少一层,读者的脑子就轻一分。
接着做一件更实用的事:把挑出来的规则逐条编译成真正的正则对象,看它们能不能编译成功,并把每条规则的正则文本打印出来。因为「正则写错了」在配置里是不会报错的——它只会在某条消息进来、new RegExp(...) 被调用的那一瞬间才抛错:
const regexRules = cfg.keywords.filter((k) => k.mode === "regex");
for (const rule of regexRules) {
try {
const re = new RegExp(rule.match, "i");
console.log("✅ 可用:", re.toString(), "→", rule.reply);
} catch (e) {
console.log("❌ 正则写错了:", rule.match, "|", e.message);
}
}
▲ 这里出现了 try / catch,我们会在第 11 节完整讲它。现在你只要看出它的意图:「试着编译这条正则;如果它坏了,不要让它把整个程序炸掉,而是打印一句人能看懂的提示。」你可以把这段代码当成一个「配置体检脚本」,以后每次改完 config.json 跑一遍。这就是工程里非常有价值的一类小工具:把「事后在某条消息上炸掉」变成「事前主动检查」。
最后留一个 reduce 的真实用途给你。假设你想统计「这个机器人的规则里各有多少条不同模式」,reduce 就是干这个的:
const counts = cfg.keywords.reduce((acc, k) => {
const mode = k.mode ?? "contains";
acc[mode] = (acc[mode] ?? 0) + 1;
return acc;
}, {});
console.log(counts);
// 上面那四条规则会得到:{ contains: 3, exact: 1 }
▲ 逐行读:reduce 接两个参数——回调函数和「初始累加值」{}。每一圈,acc 是到目前为止的结果,k 是当前这一项。acc[mode] = (acc[mode] ?? 0) + 1 的意思是「这个模式的计数加一,如果还没有就当成 0 再加」。最后 return acc 把它交回给下一圈,或者作为最终结果返回。reduce 是最难读的一个迭代方法——所以它有一个使用纪律:如果 reduce 写出来超过五行,就该改成普通的 for...of 循环。它的价值在于「汇总」,不在于炫技。
7. 函数:把一段逻辑包起来,起个名字
到这里你已经反复看到「函数」了。这一节我们把它的机制讲透,因为函数是编程里最小的抽象单位——你会不会用函数,直接决定你的代码是十行还是三百行。
7.1 定义与调用:参数进,返回值出
// 定义:给一段逻辑起个名字,并声明它需要什么
function add(a, b) {
const sum = a + b;
return sum;
}
// 调用:给进具体的值,拿回结果
const total = add(3, 5);
console.log(total); // 8
几个术语现在要一次说准:
- 参数(parameter):定义时写在括号里的名字,这里是
a和b。它们是函数内部的「局部变量」,外面的代码看不到。 - 实参(argument):调用时真正传进去的值,这里是
3和5。 - 返回值:
return后面的东西,也就是这次调用的「产物」。 return会立刻结束函数。它后面还有代码也不会执行。这一点非常重要——第 5 节的「提前返回」就是靠它实现的。
还有一件事必须现在就分清,因为它会造成大量困惑:
console.log(add(3, 5)); // 8 ← 打印「调用 add 的结果」
console.log(add); // [Function: add] ← 打印「add 这个函数本身」
// 忘记写括号,是这个错误的常见来源:
const wrong = add; // 这里没有调用!wrong 就是那个函数
const right = add(3, 5); // 这才是调用,right 是 8
▲ 「函数本身」和「函数被调用之后的结果」是两个不同的东西。当你以后看到 setTimeout(f, 1000) 和 setTimeout(f(), 1000) 的区别时,根源就在这里:前者说「一秒后请调用 f」,后者说「现在就调用 f,把它的结果一秒钟后交给 setTimeout」。第五章讲异步时你会亲手踩这个坑。
7.2 箭头函数:写法上的两种,语义上的一种
// 传统写法
function double(n) {
return n * 2;
}
// 箭头函数:一样的功能
const double = (n) => {
return n * 2;
};
// 箭头函数简写:只有一行表达式时,可以省掉花括号和 return
const double = (n) => n * 2;
三种写法做的事完全一样。你现在只需要记住一条使用习惯:
需要给一段逻辑起名字、而且要复用它的,用 function。当作参数临时传给别的函数的,用箭头函数。
理由:箭头函数短,适合塞进 .map()、.filter() 这种地方,一眼就能看完;而有名字的函数读起来更像一句话(stripAt(text) 比一个匿名箭头好读得多),也更容易在报错的堆栈里被认出来。
7.3 默认参数:解决「调用的人没传」
function greet(name = "朋友") {
return `你好,${name}`;
}
greet("小明"); // "你好,小明"
greet(); // "你好,朋友" ← 没传就用默认值
bot.js 里有一处很典型的默认参数用法(第 195 行):
call(action, params = {}, timeoutMs = 15000) { ... }
▲ 这个 params = {} 的意思是:调用时可以只传 action,后面两个参数会有合理的默认值。如果不写这个默认值,那么漏传参数时 params 就是 undefined——而后面代码里必然会用 params.xxx 去读它的属性,于是又是一个 Cannot read properties of undefined。默认参数是一种「把防御放在签名里」的写法:与其在每个函数体里写 if (!params) params = {},不如让签名直接说明「这里可以不给」。
还有一个更隐蔽的陷阱,提前告诉你:默认参数只在「传进来的是 undefined」时才生效,传 null 不算。
function f(x = 10) { return x; }
f(); // 10 ← undefined,用默认
f(undefined); // 10 ← 显式传 undefined,也算没传
f(null); // null ← 传 null 就用 null!默认值不生效
7.4 纯函数与不纯的函数:一个判断代码好坏的标准
把函数分成两类,是你会受益终身的一个划分:
纯函数:给同样的输入,永远返回同样的输出,并且不改动外面的任何东西。
不纯的函数:除了返回值之外,还和外界发生了交流——读了时间、写了文件、发了网络请求、改了外面的变量、打印了日志。
对比 bot.js 里的两个真实函数:
// 纯:同样的 text 和 vars,永远得到同样的结果,不碰任何外部状态
function render(text, vars) {
return String(text).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
}
// 不纯:它读了「现在几点」,所以同样的参数会得到不同的结果
function nowText() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
纯函数有三个巨大的好处,每一个都很具体:
- 容易测。你给它一个输入,它必须给你一个确定的输出。第九章我们会写测试——那时候你会发现,测试纯函数是几分钟的事,而测试一个「会读时间、会发网络请求」的函数要造一堆替身。
- 可以放心复用。调用它不会有副作用,所以你在任何地方调它都不用担心「会不会顺手把什么改了」。
- 出问题时好定位。如果输出不对,问题一定在里面(或者在你给的输入里),不可能在外面。
那 nowText 这种不纯的函数是不是「坏代码」?不是。「读当前时间」这件事本身就不可能纯。关键不在于消灭不纯,而在于把不纯的部分赶到边缘去:让内核的逻辑尽量纯,让「读时间、写文件、发请求」集中到少数几个地方。这页代码里就有一个漂亮的例子——我们下一节会看到,render 是纯的,而 varsFor 是不纯的(因为它在里面调了 nowText):
// bot.js 第 236–247 行,简化版
function varsFor(event, text) {
const senderName = event.sender?.card || event.sender?.nickname || String(event.user_id);
return {
botName: cfg.botName,
time: nowText(), // ← 这一行让 varsFor 变得不纯
nick: senderName,
userId: String(event.user_id ?? ""),
groupId: String(event.group_id ?? ""),
text,
};
}
▲ 这个设计是有意的:把「不纯的那一部分」集中到 varsFor 一个函数里,其余所有用到变量的逻辑(render)就都是纯的。如果反过来,让每个模板替换的地方都自己去读一次时间,那么「现在几点」就会散落在五个位置,你会永远搞不清某条回复里的时间到底是哪一刻取的。
7.5 为什么 render 值得被抽成一个函数
现在请你认真看这一行——它是这一章里最值得反复读的一行代码(bot.js 第 155–157 行):
function render(text, vars) {
return String(text).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
}
它的作用只有一句话:把文本里的 {xxx} 占位符换成 vars 里对应的值。为什么它能工作?先看那个正则:
| 片段 | 含义 |
|---|---|
\{ 和 \} | 字面上的花括号。花括号在正则里有特殊含义,所以要加反斜杠「转义」成普通字符 |
( ) | 一个捕获组,把括号里匹配到的内容单独存下来,稍后能取出来用 |
\w+ | 一个或多个「词字符」(字母、数字、下划线)。这就是占位符的名字,比如 botName |
g | 全局标志:不要匹配到第一个就停,把整段文本里所有的都替换掉 |
然后是 String.prototype.replace 的一个能力:它的第二个参数如果是一个函数,那么这个函数会在每次匹配到时被调用,参数依次是「整个匹配到的文本」和「捕获组里的内容」,而它的返回值就是替换进去的东西。
所以那一行里:(m, k) 里的 m 是整段 {botName},k 是捕获组里的 botName。箭头函数体是 (k in vars ? vars[k] : m),意思是:如果 vars 里有这个键,就用它的值;没有的话,把原来的 {botName} 原样留着。
最后这个「原样留着」的选择,是整段代码里最关键的一个判断。为什么不做成「找不到就换成空字符串」?看实际后果:
render("{nick},先歇一会儿吧", { nick: "小雨" });
// "小雨,先歇一会儿吧"
render("现在是 {time}", { botName: "OWL" }); // vars 里没有 time
// "现在是 {time}" ← 保留原样
// 如果换成空字符串,会变成:"现在是 " ← 用户看到一个断掉的句子
// 如果换成 "undefined":"现在是 undefined" ← 更糟
保留原样,是一个「把配置错误暴露出来」的选择。当用户在 QQ 里看到 现在是 {time} 时,他立刻知道「配置里写了个没有对应变量的占位符」,会来找你;而如果显示成 现在是 ,他只会觉得这个机器人有点傻,你永远不知道配置写错了。这跟第 2 节里 JSON.stringify 那个例子的思路是同一条:宁可让错误看得见,也不要让它安静地伪装成正常。
那为什么要把这四行抽成一个函数,而不是在每个需要替换的地方各写一遍 .replace(...)?这是本章要你真正带走的一个判断标准:
一段逻辑只要满足下面任意一条,就该被抽成函数:
一、它会在两个以上地方出现。(重复的代码意味着以后改一处忘一处,两处行为慢慢分叉。)
二、它有自己的名字。(「渲染模板」是一个概念,代码里应该有一个叫 render 的东西来代表它。名字本身就是文档。)
三、它需要被单独验证。(你想知道「占位符找不到时到底会输出什么」,就必须能单独调用它试一下。)
render 同时满足三条。而它现在的样子正是你应该追求的状态:纯函数、四个字符的参数名、一行实现、一眼能判断对错。你在这一章结束时的动手项目里,会亲手再写一遍这个函数——那时你会发现,自己写出来和读懂它,完全是两件事。
7.6 抽象:函数真正在做的事
序章讲过「分层」,函数是分层在你自己的代码里最微观的形态。它的机制叫抽象:把细节关进一个盒子里,给盒子贴上一个名字,从此只在需要细节时才打开它。
stripAt(text) 就是这么一个盒子。用它的人只需要知道「它会把 @ 去掉」,不需要知道里面有两个正则、一个 trim。所以 shouldUseLlm 里可以放心地写:
const clean = stripAt(text); // 一行,不用关心细节
而 onEvent 里更极端——它调用了 matchCommand、shouldUseLlm、decideStatic、llm.chat、send,自己却只有七十来行。它能读得懂,正是因为那些细节都住在别的盒子里。如果把它们全部展开平铺进 onEvent,它会变成四百行,没有人能改得动。
想一想一个函数多长算太长?你在网上会看到「不超过 20 行」「不超过一屏」这类说法。请你自己想一个问题来取代它们:如果一个函数里的某几行,你能给它们起一个自然的名字(比如「判断是不是在 @ 机器人」「清空这个人的记忆」),它们为什么还留在这个函数里?反过来想:有没有一种情况,把几行代码抽成函数反而让代码更难读?(提示:想想一个只在某一个地方用一次、名字起得很勉强、而且还依赖外面五个变量的「函数」。)
8. 数组与对象:真实的数据结构
这一节是本章的重头戏。原因是:你读不懂 bot.js,八成不是卡在语法上,而是卡在「这堆括号里到底装了什么」上。而 OWL 处理的每一条消息,都是一个「数组套对象」的结构。把它看懂,bot.js 会突然变得透明。
8.1 数组:一串有顺序的东西
const names = ["小明", "阿泽", "小雨"];
names.length // 3
names[0] // "小明" ← 下标从 0 开始
names[2] // "小雨"
names[3] // undefined ← 越界不报错,只给你 undefined
names[names.length - 1] // "小雨" ← 取最后一个的常用写法
数组的几个常用操作,每个都要知道它改不改原数组(这是一个真实而高频的坑):
| 操作 | 作用 | 改原数组吗 |
|---|---|---|
arr.push(x) | 在末尾加一个 | 改(返回新长度) |
arr.pop() | 拿走末尾一个 | 改(返回被拿走的那个) |
arr.slice(1, 3) | 取下标 1 到 2 的一份拷贝(含头不含尾) | 不改 |
arr.slice(-2) | 取最后两个 | 不改 |
arr.join("、") | 把元素拼成一个字符串 | 不改 |
str.split(",") | 字符串切开成数组(注意:这是字符串的方法) | 不改 |
arr.includes(x) | 有没有这一项 | 不改 |
const a = ["小明", "阿泽"];
a.push("小雨");
console.log(a); // ["小明", "阿泽", "小雨"] ← 原数组变了
const b = a.slice(-2);
console.log(b, a); // ["阿泽","小雨"] ["小明","阿泽","小雨"] ← a 没变
console.log(a.join("、")); // "小明、阿泽、小雨"
▲ push 会改原数组。这意味着:如果你把同一个数组传给了两个函数,其中一个 push 了一下,另一个函数看到的内容也会变。这类「我没有改它,但它变了」的 bug 极难排查,因为你在出错的地方看到的是一段完全无辜的代码。养成一个习惯:需要「加一项」但又不想动原数组时,用 [...arr, newItem](展开语法,造一个新数组)。
8.2 对象:一堆有名字的值
const user = {
name: "小明",
qq: "1876148307",
level: 3,
tags: ["高一", "爱看小说"],
settings: { mute: false }
};
user.name // "小明" ← 点号:键名是固定的时候用
user["name"] // "小明" ← 方括号:一样的效果
user.tags[0] // "高一" ← 对象里的数组
user.settings.mute // false ← 嵌套对象,可以一直点下去
user.age // undefined ← 没有这个键,不报错,给你 undefined
点号和方括号的区别,只在一种情况下变得重要:当键名是「算出来的」时候,只能用方括号。
const key = "qq";
user.key // undefined ← 它去找一个字面量叫 "key" 的键,找不到
user[key] // "1876148307" ← 它去找 key 这个变量的值所对应的键
// 上一节的 reduce 例子就是靠这个:
const mode = k.mode ?? "contains";
acc[mode] = (acc[mode] ?? 0) + 1; // 键名是变量,必须用方括号
数组和对象的分工,用一句话说清:
数组管「顺序」和「一堆同类的东西」;对象管「这一件东西的各个部分」。
一群人的名字 → 数组。一个人的名字、QQ 号、等级 → 对象。「一群人」如果每个人都要带详细信息 → 数组里装对象,这正是下面要讲的消息段格式。
8.3 解构:把里面的东西直接拿出来
// 不用解构:一行一件事,还要重复写变量来源
const name = user.name;
const qq = user.qq;
// 用解构:一行拿两个
const { name, qq } = user;
// 数组解构按位置
const [first, second] = ["小明", "阿泽", "小雨"];
// first = "小明",second = "阿泽"
// 可以顺手改名(: 后面是新名字)
const { name: nickName } = user;
// 可以给默认值
const { level = 1 } = user;
// 函数参数里直接用(bot.js 第 209 行就是这么写的)
function replyGroup(groupId, text, { atUserId, quoteMessageId } = {}) { ... }
▲ 最后一行值得单独说:「解构参数 + = {} 默认值」是一个固定搭配。它的意思是「第三个参数是一个对象,我要从里面取两个键;如果调用的人压根没传这个对象,就当成空对象处理」。bot.js 里调用它的时候是这样写的(第 364 行):
client.replyGroup(event.group_id, t, { atUserId, quoteMessageId });
▲ 左边用解构取值,右边用简写语法构造对象——{ atUserId, quoteMessageId } 等价于 { atUserId: atUserId, quoteMessageId: quoteMessageId }。「键名和变量名同名时可以只写一次」这条简写,你在读真实代码时每分钟都会遇到,认不出来会以为是什么新语法。
8.4 真实的 OneBot 消息段:数组套对象
现在到最关键的地方。你给 OWL 发一条「@她 然后说 /ping」,NapCat 推给 bot.js 的数据是这样一段(这是真实结构的简化示意,完整字段第四章会逐个讲):
{
post_type: "message",
message_type: "group",
group_id: 123456,
user_id: 10001,
self_id: 1876148307,
message_id: 9001,
sender: { card: "小明", nickname: "xiaoming", level: 3 },
message: [
{ type: "at", data: { qq: "1876148307" } },
{ type: "text", data: { text: " /ping" } }
]
}
请注意最外面那层是对象(一个事件),它的 message 键指向一个数组,数组里的每一项又是一个对象,每个对象又有 type 和 data 两个键——而 data 里还是一个对象。这就是「数组套对象」。
现在你可以把 bot.js 里的每一处访问读明白了。我们逐个数:
| 代码 | 读出来的东西 | 如果它不存在会怎样 |
|---|---|---|
event.post_type | "message" | undefined,于是 !== "message" 为真,函数直接返回 |
event.message_type | "group" | undefined,会被当成「不是群聊」,走私聊分支 |
event.sender?.card | "小明" | undefined(不报错),接着试 nickname |
event.message | 那两段的数组 | segmentsToText 里 Array.isArray 挡住,返回 "" |
event.message[0].type | "at" | 下标越界 → undefined.type → 报错 |
▲ 最后一行解释了一件事:为什么 bot.js 里到处是 ?.,却很少看到对数组下标做防护。因为「对象缺一个键」是常态(不同协议端给的字段不一样),而「消息段数组是空的」在 isAtBot 里已经被 Array.isArray + .some() 天然处理掉了——空数组的 .some() 永远返回 false,不会去读下标。
还有一个细节值得你停一下:为什么 QQ 号在这里是数字 1876148307,而在 config.json 里是字符串 "1876148307"?——这正是第 3 节那个坑的真实版本。所以 isAtBot 里必须两边都转成字符串才能比:
// bot.js 第 159–163 行
function isAtBot(event) {
const selfId = String(event.self_id ?? "");
const segs = Array.isArray(event.message) ? event.message : [];
return segs.some((s) => s.type === "at" && String(s.data?.qq) === selfId);
}
▲ 注意 String(s.data?.qq) 和 selfId 的比较:两边都被 String() 包过,所以 === 才可能为真。如果这里偷懒写成 s.data?.qq === event.self_id,那么当一边是 1876148307(number)、另一边是 "1876148307"(string)时,结果是 false——机器人会完全不认自己被 @ 了,而且日志里什么都不会显示,你只会看到「@ 她她不理」。这是一个真实、安静、且极易发生的 bug。
8.5 逐步拆解 segmentsToText:为什么消息不是一个字符串
现在读这一章的核心函数(bot.js 第 132–153 行,原样):
function segmentsToText(segments) {
if (typeof segments === "string") return segments;
if (!Array.isArray(segments)) return "";
return segments
.map((s) => {
switch (s.type) {
case "text":
return s.data?.text ?? "";
case "image":
return "[图片]";
case "face":
return "[表情]";
case "at":
return `@${s.data?.qq ?? ""}`;
case "reply":
return "";
default:
return `[${s.type}]`;
}
})
.join("");
}
先回答那个最根本的问题:为什么聊天消息不是一句字符串,而是一个结构?
因为一条消息里混着不同种类的内容。你发的那句「@OWL /ping」在 QQ 眼里不是一段文字,而是两个动作:「@ 一个特定的人」和「说一段文字」。这两件事的性质完全不同——前者需要 QQ 号才能生效(你手打一个「@OWL」只是三个普通字符,不会真的提醒她),后者才是内容。
再看看一条更复杂的消息:「看这个 [图片] 哈哈 [表情]」,或者引用别人的话再回复。这时候一个纯字符串就没法表达「这段是引用、这段是表情、这段是文字」了——你只能自己发明一套标记规则(比如 QQ 早期用的 [CQ:image,file=xxx] 那种)。结构化的数组比标记字符串更不容易歧义:你不用去猜「如果用户真的打出了 [CQ:image 这几个字符怎么办」。
顺带说,你会在 bot.js 里看到这两种东西同时存在:varsFor 里有一个 at: `[CQ:at,qq=${event.user_id}]`,那是给模板用的「CQ 码」形式。第四章会把 CQ 码和消息段数组这两个体系的关系讲清。现在你只需要知道:消息段数组是「结构」,CQ 码是「字符串标记」,两者可以互相转换。
然后逐行读这个函数:
| 代码 | 它在解决什么问题 |
|---|---|
if (typeof segments === "string") return segments; | 有些协议端/配置会把 message 直接给成字符串(叫「CQ 码模式」)。这时候没什么可转换的,原样返回。这就是第 3 节讲的「在边界上做归一化」 |
if (!Array.isArray(segments)) return ""; | 如果它既不是字符串也不是数组(null、undefined、一个数字……),返回空字符串。为什么不直接报错?因为这是一条外部输入的消息:一条格式不对的消息不该让整个机器人崩溃,最差的结果应该是「这条消息被当成空消息」 |
.map((s) => {...}) | 把每一个消息段各自转成一小段纯文本,得到一个新数组。注意这时还没拼起来 |
case "text": return s.data?.text ?? ""; | 文本段就取它的文字。?. 挡住 data 缺失,?? "" 挡住 text 缺失 |
case "image": return "[图片]"; | 图片对文本逻辑没有意义,但要留一个占位符。为什么不能直接丢掉?因为「我发了一张图」和「我什么都没发」是两种不同的消息,丢掉会让后面的逻辑误判 |
case "at": return `@${s.data?.qq ?? ""}`; | 把 @ 还原成 @QQ号 的文字形式。这一步很关键:它让下游的 stripAt 有东西可删 |
case "reply": return ""; | 引用段(用户引用了某条消息)对文本没有贡献,丢掉。但这里有个问题,见下面的思考题 |
default: return `[${s.type}]`; | 未知类型统一显示成 [video] 这样的标记。第 5 节讲过为什么一定要有兜底 |
.join("") | 用空字符串把上面那些碎片拼成一条文本。用 "" 而不是 " ",是因为文字段之间本来就带着空格(腾讯的输入里「@她 /ping」那个空格属于第二个 text 段) |
走一遍真实数据,把中间的每一步都写出来。输入是前面那段 message:
// 第 1 步:map 逐个转换
"at" 段 → "@1876148307"
"text" 段 → " /ping"
// 第 2 步:得到中间数组
["@1876148307", " /ping"]
// 第 3 步:join("") 拼起来
"@1876148307 /ping"
▲ 这就是 segmentsToText 的产物,它是一个字符串。然后 stripAt 把它变成 "/ping",matchCommand 拿 "/ping" 去和 "/ping" 比,命中,返回 "pong 🏓"。从「用户按了发送」到「她回了 pong」,整条链路上每一环你现在都能说出来了。这就是「机制层」的理解——序章说的那种「能看见它怎么动」的状态。
想一想case "reply": return ""; 丢掉的是引用信息。现在设想一个场景:群里有人引用了一条骂人的消息,然后 @ 机器人说「你觉得这话对吗」。传给 AI 的文本里,那句被引用的内容已经消失了,AI 只看到「你觉得这话对吗」。这会造成什么后果?如果你想让 AI 知道被引用的内容,你需要在 segmentsToText 里怎么改?(提示:你需要去别的地方取那条被引用的消息——这涉及一个新的网络请求。这个改动值不值得做,代价是什么?)
8.6 对象作为配置:嵌套对象的安全读取
对象最常见的用法之一是当「配置」用。bot.js 里所有行为都由一个 cfg 决定,而它是从 config.json 读进来的一个嵌套对象:
{
"botName": "OWL",
"prefix": "/",
"cooldownMs": 1200,
"reply": {
"keywordReply": true,
"atReply": true,
"quoteReply": true
},
"llm": {
"enable": true,
"trigger": { "at": true, "private": true, "keyword": false, "keywordList": [] },
"limits": { "userPerMinute": 6, "maxInputChars": 400 }
}
}
要读 cfg.reply.quoteReply,链路上有三级。这就是「嵌套对象的安全读取」问题:只要中间任何一级是 undefined,整条链就崩。实战里有三种处理方式,各有各的代价:
| 写法 | 读不到时的行为 | 代价 |
|---|---|---|
cfg.reply.quoteReply | 直接抛错,程序崩溃 | 配置少一段就整体挂掉 |
cfg.reply?.quoteReply | 得到 undefined | 行为变成「当作关闭」,安静但安全 |
cfg.reply?.quoteReply ?? false | 得到 false | 多打几个字,但类型明确(一定是布尔) |
bot.js 用的是第三种思路的变体——它在启动时就把所有可选的配置补全,让后面所有代码都可以放心地点下去(第 34–52 行,节选):
function loadConfig() {
const cfg = readJson(CONFIG_PATH);
cfg.keywords ??= [];
cfg.commands ??= [];
cfg.reply ??= {};
cfg.botName ??= "Bot";
cfg.prefix ??= "/";
cfg.cooldownMs ??= 1500;
cfg.llm ??= { enable: false };
cfg.llm.trigger ??= { at: true, private: true, keyword: false, keywordList: [] };
cfg.llm.limits ??= {};
return cfg;
}
▲ 这十几行的作用,是整个文件里性价比最高的防御。代价是每次读配置都要多写一个 ?.,而这个写法把它一次性买断了:从这行往后,cfg.llm.limits.maxInputChars 这样的访问就不需要 ?. 了——虽然 bot.js 里出于习惯还是写了 cfg.llm?.limits?.maxInputChars(第 410 行)。这叫「在边界处收紧,在内部放开」:不可信的地方(外部 JSON)全力防御,内部代码则假设数据已经是规整的。
然后还有一个真实的坑,就藏在这个 readJson 里(第 29–32 行):
function readJson(file) {
const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(raw);
}
▲ 那个 .replace(/^\uFEFF/, "") 在删一个「零宽字符」——字节序标记(Byte Order Mark,BOM)。注释里说了原因:用 PowerShell 的 Set-Content -Encoding UTF8 存过的文件会带这个标记,而 JSON.parse 遇到它会直接报错。于是现象是「我只是改了一下配置文件,机器人就起不来了」,而文件内容看起来完全正确。这个坑我们会在第一章/第四章再遇到,它属于「编码」这个大主题。
最后给你一个读嵌套对象时的通用提问法。每次写下一个点号之前,先问:左边这个东西,凭什么一定存在?
- 如果答案来自「我刚写下的字面量」——安全,不用防。
- 如果答案来自「我读的文件」——要防,因为文件是别人可以改的。
- 如果答案来自「网络回来的数据」——必须防,因为那是完全不可控的。
- 如果答案来自「我上一个函数返回的」——看那个函数有没有可能返回
undefined。
这四个问题能省掉你未来无数个 Cannot read properties of undefined。第 11 节我们会用一个真实的报错把这套方法走一遍。
9. 字符串处理与正则入门
聊天机器人本质上是一个「文本进、文本出」的程序。所以字符串处理是你这一章最该练熟的手艺。
9.1 六个你会天天用到的字符串方法
const s = " @1876148307 你好 ";
s.trim() // "@1876148307 你好" 去掉两端的空白(注意:中间的不去)
s.includes("你好") // true 有没有这一段
s.replace("你好", "您好") // 换掉第一个匹配
s.replace(/你/g, "您") // 用正则 + g,换掉所有匹配
s.slice(0, 3) // " @" 从下标 0 取到 2(含头不含尾)
s.slice(-2) // " " 最后两个字符
s.split(" ") // ["", "", "@1876148307", "你好", "", ""] 按空格切
"5".padStart(3, "0") // "005" 左边补到长度 3
"abc".toUpperCase() // "ABC"
"ABC".toLowerCase() // "abc"
"小明".length // 2 ← 中文字符也算 1
其中 padStart 你在 bot.js 里已经见过了——时间是靠它补零的(第 229 行):
function nowText() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
▲ 逐行读:d.getMonth() 返回的是 0 到 11,所以必须 + 1 才是人类说的月份(这是 JavaScript 里另一个著名的历史遗留)。p 这个小函数把任何数字变成两位字符串:7 → "07",12 → "12"。如果没有它,你会看到 2025-6-1 9:5:3 这种时间——能用,但一眼看不出是几点几分,而且没法按字符串排序。先把 String(n) 转成字符串是必须的:padStart 是字符串的方法,数字没有它。(这也解释了一个常见的错:(7).padStart 会报 padStart is not a function。)
bot.js 里还有一个真实的字符串操作,是拿来写日志的(第 368 行):
log(`${tag}: ${t.replace(/\n/g, " / ")}`);
▲ 它把回复文本里的所有换行换成 " / "。为什么?因为日志是一行一条的。如果日志内容里带着换行,一条日志就会在文件里变成好几行,那些按行读日志的工具(grep、tail)就会被误导,你会以为发生了三条事件。「日志里的换行必须被消掉」是一条实战纪律,它和第一章讲的 grep 直接相关。
9.2 正则:描述「一类字符串」的语言
有些判断用 includes 表达不了。比如「这一段是不是一个 QQ 号」、「有没有连续三个以上数字」、「这行日志里的 QQ 号是多少」。这些需要正则表达式——一种专门用来描述字符串模式的小语言。
先给你最小的一套符号,够你用很久:
| 写法 | 含义 | 匹配的例子 |
|---|---|---|
\d | 一个数字(等价于 [0-9]) | "7" |
\w | 一个「词字符」:字母、数字、下划线 | "a" "7" "_" |
\s | 一个空白:空格、制表符、换行 | " " |
. | 任意一个字符(默认不含换行) | "好" |
* | 前面那个东西出现 0 次或多次 | \d* 匹配 "" 和 "123" |
+ | 前面那个东西出现 1 次或多次 | \d+ 匹配 "123",不匹配 "" |
? | 出现 0 次或 1 次 | @!? 匹配 "@" 和 "@!" |
{3,15} | 出现 3 到 15 次(这就是「量词」) | \w{3,15} 匹配 "abc"、"1876148307" |
(a|b) | a 或 b,括号是一个分组 | (好|真)累 匹配 "好累"、"真累" |
[abc] / [^abc] | 这些字符之一 / 这些字符之外的一个 | [\w-] 匹配字母数字下划线或短横线 |
^ / $ | 字符串的开头 / 结尾 | ^\d+$ 匹配「整串都是数字」 |
然后是标志(flags),写在正则的最后面(/.../gi)或者作为 new RegExp(模式, 标志) 的第二个参数:
| 标志 | 含义 | 不加会怎样 |
|---|---|---|
g | 全局:找出/替换所有匹配 | 只处理第一个匹配到的地方 |
i | 忽略大小写 | /owl/ 匹配不到 "OWL" |
bot.js 里用 "i" 的地方是关键词的正则模式(第 285 行):
new RegExp(needle, "i").test(clean)
▲ 这里用 i 是有道理的:用户在群里打「Hello」还是「hello」都应该命中。但注意它没有用 g——因为 .test() 只回答「有没有匹配到」,不需要找出所有匹配。给 .test() 加 g 不仅没用,还会制造一个真实的 bug:带 g 的正则对象会记住上次匹配到的位置,同一个正则对象连续调用 .test() 会得到交替的 true/false。这是一个值得记下来的坑。
9.3 stripAt 的真实需求:为什么不能贪婪
现在讲这一节最重要的一个函数。它的需求是:把消息里的 @某人 去掉,只留正文。真实实现(bot.js 第 165–172 行):
function stripAt(text) {
// QQ 号只含数字/字母/下划线/短横线;不能贪婪到把紧跟的中文内容一起吃掉。
// OneBot 也支持 <@!123> 这种形式,一并处理。
return String(text)
.replace(/<@!?\d+>/g, "")
.replace(/@[\w-]{3,15}\s*/g, "")
.trim();
}
为什么这里值得写两行注释?因为 [\w-] 和 {3,15} 都不是随手写的。下面我们按同一条推理线走一遍:先确定「@ 后面跟的东西由哪些字符组成」,再由此推出字符类,最后才讨论长度范围。顺序不能颠倒——颠倒过来就会得出错误结论。
先花十秒读掉第一个正则 /<@!?\d+>/g,它比第二个简单得多:< 和 > 是字面上的尖括号,@ 是字面的 at,!? 表示「感叹号可以有也可以没有」,\d+ 是一个或多个数字。合起来它匹配 <@12345> 或 <@!12345>——这是某些客户端/平台表示「提到某人」的另一种写法,两种形式在 OneBot 生态里都可能出现,所以两行都要有。g 表示一句话里出现多次也全删掉。
第一步:先问「@ 后面跟的是什么」,而不是先想正则。用户说「@某人」,但在数据层面,@ 后面跟的是那个人的标识符:绝大多数情况下是一个 QQ 号,也就是纯数字。bot.js 自己的 segmentsToText 会把 @ 段还原成 @QQ号(你在 8.5 节看到过 @1876148307),所以我们处理的输入本质上就是「@ + 一串 QQ 号」。
那为什么要写成 [\w-](数字、字母、下划线、短横线)而不是干脆只写 \d(纯数字)?因为除了 QQ 号,还可能遇到两类东西:一是某些工具或平台的账号名里带字母和下划线(比如 @OWL_bot),二是别的实现会把 @ 段渲染成带短横线的形式。把字符类放宽到「标识符可能出现的全部字符」,是为了容纳这些情况;而把它限制在这四类,是为了不误伤正文。
这里有一个需要说清的取舍:中文昵称的 @ 是删不掉的。如果某个协议端把 @ 段渲染成 @小明(也就是说 qq 字段里放的是昵称而不是号码),那么 [\w-] 匹配不到它——这是这个方案的已知代价,不是 bug。它换来的好处是:任何情况下都不会有中文被吃掉的意外。第 9.4 节末尾那个思考题就是让你去权衡这个取舍。
第二步:由这个结论直接推出字符类。「数字、字母、下划线」在正则里正好是 \w(word character),再加上短横线用 -,写成一个字符类就是 [\w-]。字符类的职责是「只肯吃这一类字符」,遇到别的就立刻停下。这一条才是防止误伤中文的根本原因——因为中文压根不在这个类里。
▲ 这里要先纠正一个很常见的错误说法。如果你把字符类去掉,写成 @.+(@ 后面跟任意字符),会怎样?答案是它不是「把中文也吃掉」,而是把整条消息全部吃光。因为 . 能匹配空格,而 + 是贪婪的,它会一路吃到字符串结尾。实测:
const bad = (text) => String(text).replace(/@.+/, "").trim();
bad("@1876148307 你好"); // "" ← 整句话都没了,不只是中文
bad("@OWLbot你好"); // ""
bad("@OWLbot 你好"); // ""
bad("@OWL 你好 我是小明"); // ""
▲ 注意我在这个坏版本里没有写 g——正则里也没有 \s*。这两处都是刻意的,理由在下面第三、四步里会分别说清。现在你只需要看到这个后果:一条正则里少了一个字符类,整条消息就消失了。而且它不报错——机器人会收到一个空字符串,然后当作「用户什么都没说」处理。
第三步:再决定长度上限,而这一步的答案是「不追求精确」。字符类解决「能吃哪类字符」,量词解决「最多吃多少个」。+ 是「一个或多个,不限上限」;{3,15} 是「3 到 15 个」。为什么要有上限?把 + 和 {3,15} 放在一起对比,你会看到差别只出现在特别长的标识符上:
const unlimited = (text) => String(text).replace(/@[\w-]+\s*/g, "").trim();
// 正确版本仍然按 @[\w-]{3,15}\s* 处理
unlimited("@1876148307 你好"); // "你好"
stripAt ("@1876148307 你好"); // "你好" ← 短标识符上两者一致
unlimited("@OWL_robot_2024_official 嗨"); // "嗨" ← 整串被吃掉
stripAt ("@OWL_robot_2024_official 嗨"); // "official 嗨" ← 只吃前 15 个字符
▲ 逐行走一遍第二组。@OWL_robot_2024_official 这一串是 25 个字符。unlimited 不限长度,于是整串连同后面的空格一起消失,只剩下 "嗨"。stripAt 的 {3,15} 只允许吃 15 个,恰好落在 @OWL_robot_2024_(@ 加上 14 个字符,末尾那个下划线属于 \w,所以被吃掉了)——剩下的是从句号开始的那半截 official,拼上后面的 " 嗨",得到 "official 嗨"。
那为什么说这一步「不追求精确」?因为 {3,15} 既救不回 official 这半截,也不保证覆盖所有情况——它是一个防御性的刹车,不是一条业务规则。它真正防的是两件事:
- 防「吃太多」。想想如果 @ 后面跟着一个 200 字符的账号名,或者用户直接粘贴了一长串英文字符(比如一串 base64)。不限长度时它会全部吃掉,你的日志、你的长度限制、你的回复都会基于一段被清空的文本。
{3,15}让最坏情况下的损失被限制在 15 个字符内。删多了是信息丢失,删少了只是不干净——两者不对等,所以要往下限小的方向偏。 - 防正则「跑飞」。在面对很长的、由词字符组成的内容时,不限长度的
+会让匹配范围变得很宽。在更复杂的正则里,这种宽范围会带来性能问题甚至「正则回溯爆炸」——一个能把你服务器 CPU 打满的真实攻击手法。你会在第九章的安全部分看到它。现在记住这条原则就够:写正则时,永远问一句「它能匹配的最长情况是什么」。
第四步:最后才处理「后面那个空格」,以及为什么要 g。两个都是实测出来的需求,不是猜的。
\s*:用户输入「@她 /ping」时,那个空格是消息分段留下的(我们在 8.5 节看到第二段是" /ping",前面带空格)。正则如果不吃掉它,stripAt之后会得到" /ping",而matchCommand里是拿"/ping"去比——差一个空格,匹配失败。最后那个.trim()是双重保险。g:一句话里可能有多个 @。g决定「只删第一个」还是「全部删掉」。不过实测会告诉你一个需要留意的细节:
const noG = (text) => String(text).replace(/@[\w-]{3,15}\s*/, "").trim();
noG("@OWL @1876148307 你好"); // "@1876148307 你好" ← 只删了第一个
stripAt("@OWL @1876148307 你好"); // "你好" ← 两个都删掉了
noG("@OWL @小明 你好"); // "@小明 你好"
stripAt("@OWL @小明 你好"); // "@小明 你好" ← 和上面完全一样
▲ 两组对比给出了两种不同的结论,这正是本节要训练的读法。第一组里,g 的作用清清楚楚:没有它,@1876148307 会留在正文里,于是 matchCommand 拿到的是 "@1876148307 你好" 而不是 "你好",关键词可能因此匹配失败。第二组里加不加 g 毫无差别——因为 @小明 里的中文字符不在 [\w-] 里,正则根本没匹配到它,删不删都轮不到 g 说话。「中文没被吃掉」是第二步那个字符类的功劳,不是 g 的功劳。如果你把这两件事混在一起,就会得出「g 能防止误伤中文」这种错误结论,然后在某天发现真正的问题在别处。
顺手把它跑一遍,看你自己的直觉准不准。下面每一行的输出都是实测值,你粘进 REPL 应该得到完全一样的结果;如果不一样,那不是你错了,是环境或版本的问题,请把差异当线索而不是当挫折。
const stripAt = (t) => String(t)
.replace(/<@!?\d+>/g, "")
.replace(/@[\w-]{3,15}\s*/g, "")
.trim();
stripAt("@1876148307 你好") // "你好"
stripAt("@1876148307你好") // "你好" ← 中英之间没有空格也能处理
stripAt("<@!123> 你好") // "你好"
stripAt("我最近好累") // "我最近好累" ← 没有 @,原样返回
stripAt("@a 你好") // "@a 你好" ← 只有 1 个字符,不够 3 个,不匹配!
stripAt("@OWL_robot_2024_official 嗨") // "official 嗨" ← 只吃前 15 个字符,剩下半截
stripAt("@OWL @小明 你好") // "@小明 你好" ← 中文不在 [\w-] 里,删不掉
▲ 后三行是这段代码的真实边界,值得你盯着看一会儿。第五行说明:如果某个标识符只有 1 到 2 个字符,@a 是删不掉的——因为 {3,15} 要求至少 3 个字符。第六行说明:过长的标识符会被切掉一半,留下一段 official 这样的残片。第七行说明:中文名字的 @ 完全删不掉,这是 [\w-] 的必然结果。这三条都不是 bug,是「用一个足够好的规则去覆盖绝大多数情况」的取舍。一个新手会想写「完美」的正则;一个有经验的人会问:「不完美的那 1% 会发生什么?那个后果我能不能接受?」
如果需要更准的版本,方向是「用位置而不是长度来界定」——比如只删 @ 后面紧跟的一串数字(QQ 号不会有字母),或者先判断「被 @ 的是不是机器人自己」再决定删不删。但每一种「更准」都会带来新的误伤可能,而收益只是让文本干净一点。这是一次值得你自己做的权衡;把它写下来,比抄一条正则有用。
想一想请你设计一个能同时解决上面两个边界情况的正则(提示:可以不用限制长度,而是要求 @ 后面紧接着就是 QQ 号——但如果对方是群名片而不是 QQ 号呢?)。在你动手之前先回答一个问题:你凭什么知道 @ 后面跟的到底是 QQ 号,还是昵称?如果你不知道,那么这两种情况是不是根本没法用一条正则同时处理?——注意,这个「没法同时处理」的结论本身就是一个正确答案。
9.4 match 与 exec:把匹配到的东西取出来
.test() 只回答「有没有」。如果你要取出匹配到的内容,需要 .match()(字符串的方法)或 .exec()(正则的方法):
const line = "[2025-05-31 09:15:03] 📩 [群 123456] 小明(1876148307): 你好";
// 第一版:连续 5 到 11 位数字,两边不能再是数字
const m = line.match(/(?<!\d)\d{5,11}(?!\d)/);
console.log(m[0]); // "123456" ← 咦?取到的是群号,不是 QQ 号
// 第二版:把所有 5 到 11 位数字都捞出来
console.log(line.match(/\d{5,11}/g));
// ["123456", "1876148307"] ← 群号和 QQ 号都在,你分不清哪个是哪个
▲ 这段代码值得你亲手跑一遍,因为它演示了「练习写正则」的正确姿势:先写一版,拿真实数据一跑,发现它不对,然后再修。上面两次尝试都没解决问题:第一次被群号抢先,第二次两个都捞出来了却分不清身份。这非常正常。没有任何人能一次写对一条从日志里捞东西的正则,包括写了很多年的人。区别只在于:有经验的人会准备几条真实的输入来试,而新手会写完就相信它是对的。
顺带解释一个你可能已经在好奇的现象:日期里的 2025 是四位数字,\d{5,11} 匹配不到它——匹配是从每个位置各自尝试的,不会把 2025-05-31 里的数字连起来数。正则引擎的规则是:从左往右扫,在每个位置尽力往右吃;吃不到足够长度就换下一个位置重来,绝不会「跳过一个字符继续接上」。理解了这一点,你就能预测大部分「为什么它匹配到了这个、没匹配到那个」的问题。
给你一个更可靠的方向(先别急着看答案,自己试五分钟):日志的格式是固定的,发送者那一段的形态是 昵称(QQ号):,所以你可以用「左括号 + 数字 + 右括号 + 冒号」来定位它,而不是靠「数字的长度」。试着写出来,然后用这行日志验证它取到的是 1876148307 而不是 123456。
关于命名捕获组,还有一个小知识现在告诉你,以后用得上:(?<year>\d{4}) 这种写法会给捕获组起名字,之后可以用 m.groups.year 取出来,比 m[1] 好读得多:
const m = "[2025-05-31]".match(/(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})/);
console.log(m.groups.y, m.groups.mo, m.groups.d); // "2025" "05" "31"
最后提醒一个初学者一定会踩的坑:.match() 在找不到时返回 null,不是空数组。
const m = "你好".match(/\d+/);
console.log(m[0]); // ❌ TypeError: Cannot read properties of null (reading '0')
console.log(m?.[0]); // ✅ undefined,安全
if (m) { console.log(m[0]); } // ✅ 更清楚的写法
▲ 注意这次的报错是 null 而不是 undefined:Cannot read properties of null。这也是一个区分 null 和 undefined 的实际场景——报错文案会诚实地告诉你它遇到的是哪一个。
10. 作用域、闭包与 this:够用就好
这一节的三个词听起来很吓人,但它们要解决的问题都很朴素。我只讲你读 bot.js 真正用得上的部分,剩下的留到你有需要时再回来。
10.1 作用域:一个变量在哪些地方能被看见
const globalOne = "外面"; // 模块级:整个文件都能看见
function demo() {
const inFunction = "函数里"; // 函数作用域:只有这个函数里能看见
if (true) {
const inBlock = "块里"; // 块作用域:只有这个花括号里能看见
console.log(globalOne, inFunction, inBlock); // ✅ 三个都能看见
}
console.log(inBlock); // ❌ ReferenceError: inBlock is not defined
}
demo();
console.log(inFunction); // ❌ 也看不见
规则只有三句话,记住它你就掌握了 90%:
const/let的作用域是「最近的一对花括号」。出了那对括号就不存在了。- 里面能看见外面,外面看不见里面。函数可以读到定义在它外面的变量,但外面读不到它内部的变量。
- 同名的时候,里面盖住外面。这叫「遮蔽」(shadowing),它经常是 bug 的来源:如果你在函数里又写了一个
const text = ...,而外面也有一个text,那么函数内部看到的永远是你新写的那个,外面的被挡住了。
这里有一个必须解释的细节,因为它和你的直觉相反:为什么第 10 行的 console.log(inBlock) 放在 if 花括号外面就读不到了?因为在 JavaScript 里,if、for、while 的花括号就是块作用域的边界。这就是 var 和 let 最实际的差别——如果把上面的 const inBlock 换成 var inBlock,第 10 行就不会报错,它会打印 "块里"。而这种「出了块还活着」的变量,正是让程序行为变得难以预测的原因之一。
10.2 bot.js 里的作用域实战
看一段你熟悉的代码,注意每一层的身份(bot.js 第 132–153 行,省略中间):
function segmentsToText(segments) { // ← 函数开始
if (typeof segments === "string") return segments;
if (!Array.isArray(segments)) return "";
return segments
.map((s) => { // ← 回调函数,一层新的作用域
switch (s.type) {
case "text":
return s.data?.text ?? "";
...
}
})
.join("");
}
▲ 这里面有三个嵌套的作用域:模块(最外)、segmentsToText、以及那个箭头回调。s 是回调自己的参数,只在这个回调里存在;而 segments 是外层函数的参数,回调里能看见它(虽然这里没用到)。s 每一轮循环都是新的一个变量,所以你在 .map() 里对 s 做任何事都不会影响别的项。
为什么一个函数里要少放变量?给你一个可以量化的理由:读代码时,你的脑子里要同时装下「当前作用域里所有活着的名字」。一个函数里有三个变量你能轻松读完,有十五个你就得反复往上翻——这就是为什么「短函数」和「少用全局变量」不是审美偏好,而是直接降低阅读成本的手段。
10.3 闭包:为什么函数能记住外面的东西
先说结论:闭包就是「一个函数,加上它出生时能看见的那些变量」。函数被创建的时候,会把它外层作用域里用到的东西一起带上;之后无论它在哪儿被调用,它都还能访问那些东西。
最小的例子,就是一个能计数的函数:
function makeCounter() {
let count = 0; // 这个变量在 makeCounter 的作用域里
return function () { // 返回的这个内部函数「记住了」count
count = count + 1;
return count;
};
}
const next = makeCounter();
console.log(next()); // 1
console.log(next()); // 2
console.log(next()); // 3
// 注意:makeCounter 早就执行完了,但 count 没有被丢掉
const other = makeCounter(); // 一个新的、独立的 count
console.log(other()); // 1 ← 和上面那个互不影响
这就是闭包的全部魔法:makeCounter 已经返回了,但它的局部变量 count 还活着——因为返回出去的那个函数还在用它。JavaScript 的引擎不会回收「还有人记得的」变量。
现在讲 bot.js 里的真实案例。冷却功能是这样的(第 174–183 行):
// ---------------------------------------------------------------- 冷却控制
const lastReplyAt = new Map();
function throttled(key, windowMs) {
const now = Date.now();
const last = lastReplyAt.get(key) ?? 0;
if (now - last < windowMs) return true;
lastReplyAt.set(key, now);
return false;
}
▲ 这里的 lastReplyAt 是一个 Map(映射表),你可以先把它当成「一个更强大的对象」:它的键可以是任何类型,而且有 .get() / .set() / .has() / .delete() 这些明确的方法,比用一个普通对象 {} 更不容易踩到「原型上的键」之类的坑。它记的是「每个会话上次回复是什么时候」。
它和闭包的关系在这里:lastReplyAt 定义在文件最外层,而 throttled 每次被调用时都能读到它、改它。这就是「状态」——一个跨调用活下来的值。冷却之所以能工作,全靠这个活在外面的 Map:
// 第一秒发一条
throttled("kw:group:123456", 1200); // false ← 记录下时间,允许回复
// 0.5 秒后又发一条
throttled("kw:group:123456", 1200); // true ← 还没到 1.2 秒,拦下来
// 1.5 秒后又发一条
throttled("kw:group:123456", 1200); // false ← 够了,允许
▲ 逐行读 throttled:Date.now() 给你当前时间的毫秒数(一个巨大的整数,比如 1700000000000)。lastReplyAt.get(key) ?? 0 取上次的时间;如果这个 key 从来没出现过,.get() 返回 undefined,?? 0 把它变成 0——0 和「现在」的差当然大于 1200,所以第一次一定放行。if (now - last < windowMs) return true; 是核心判断:间隔太短就返回 true(表示「被限流了」)。注意被拦下的时候我们没有更新时间戳——这意味着不会出现「刷屏反而把冷却无限延长」的情况。最后一行记录时间并返回 false。
顺便说一个真实的细节:lastReplyAt 永远不会被清理。每个新群、每个新用户都会往里面加一条记录,一直加到进程重启。对一个只有几十个群的小机器人来说这完全没关系(一条记录几十字节);但如果 OWL 面对十万个群,这个 Map 会慢慢吃掉内存。这种「只增不减的状态」有一个名字叫内存泄漏,我们会在第六章讲记忆的时候正经处理它——那时你会学到 TTL(Time To Live,存活时间)和淘汰策略。
想一想请你说出 throttled 里「被拦下时不更新时间戳」这个设计的一个后果。想好之后,再考虑另一种设计:改成「每次调用都更新时间戳」。在下面两个场景里,这两种设计分别会表现成什么样?场景一:一个人以每 1.1 秒一条的频率不停地发消息(冷却窗口是 1.2 秒)。场景二:一个人以每 0.5 秒一条的频率刷屏。哪种设计对「真诚的对话者」更友好?哪种对「刷屏者」更严厉?这两种设计在工程里都有名字,也都有人在用。
10.4 this:一个你暂时只需要避开的东西
this 是 JavaScript 里最容易把人绕晕的一个关键字。它指的是「谁在调用这个函数」,而这个「谁」在运行时才确定,还会因为调用方式不同而变。好消息是:你在这一章、乃至整份课程的大部分代码里,都可以不用它。给你两条够用的规则:
- 在函数里,不要依赖
this。把你需要的值当参数传进去。这样函数是纯的,行为可预测。bot.js里几乎所有的工具函数(render、stripAt、segmentsToText、throttled)都不碰this,这不是巧合,是纪律。 - 在回调里优先用箭头函数。箭头函数有一条重要的性质:它没有自己的
this,它会沿用外层函数的this。所以在一个类的方法里写.map((x) => ...)时,箭头函数里的this和外层一样;而如果用function (x) {...},里面的this就变成别的东西了,你会看到this.xxx is not a function这种莫名其妙的报错。
那什么时候必须用 this?当你写「类」的时候。bot.js 里的 OneBotClient 就是唯一一个例子:
class OneBotClient {
constructor(ws) {
this.ws = ws; // 把传进来的连接存成这个实例的属性
this.selfId = null;
}
call(action, params = {}, timeoutMs = 15000) {
return new Promise((resolve) => {
if (this.ws.readyState !== this.ws.OPEN) return resolve(null);
...
});
}
}
▲ this.ws 的意思是「这个客户端实例身上的 ws 属性」。为什么这里要用它?因为 call 这个方法需要知道「我该往哪条连接发消息」,而这个信息必须存在实例上,不能靠参数传(否则每次调用都要带一遍)。「类」是当你需要「一份数据 + 一组操作这些数据的方法」时才值得用的工具。本章的动手项目里我们不用类——你会看到纯函数版本一样能写完整个处理器,而且更好读。什么时候该上类,第九章讲架构时会再讨论。
要点这一节如果你只记住一件事,请记住这个顺序:先学会用「参数进、返回值出」的纯函数解决一切;等真的遇到「一份状态要被很多操作共享」的时候,再去学类与 this。反过来做(先学 this 再学函数)是很多人卡住一个月的路径,而且卡住的原因和聪明程度完全无关,纯粹是概念出现的顺序错了。
11. 错误与调试:读懂机器对你说的话
这一节的目标很具体:让你看到红色报错时不再慌。报错不是惩罚,是计算机在给你线索——它是你能拿到的最诚实的反馈。
11.1 错误也是一个对象
当 JavaScript 出错时,它会造一个「错误对象」丢出来。这个对象上最常用的两个属性是:
try {
JSON.parse("{ 这不是合法 JSON }");
} catch (e) {
console.log(e.name); // "SyntaxError"
console.log(e.message); // "Unexpected token 这 in JSON at position 2"(不同 Node 版本措辞略有差别)
console.log(e.stack); // 一大段:错误信息 + 完整的调用链与行号
}
▲ e.name 是错误类型,e.message 是人类可读的说明,e.stack 是「从哪里一路调用到这里」的路径。你会在 bot.js 里看到它用 e.message 而不是整个 e 来写日志(比如第 125 行 log("❌ config.json 解析失败,继续使用旧配置:", e.message))——因为日志是一行一条的,e.stack 会带一堆换行把它撑成十几行。
11.2 try / catch 的正确用法
try {
// 你怀疑可能出错的、并且你能处理的代码
} catch (e) {
// 出错之后要做什么
}
关键在括号里那两句话。绝大多数新手把 try 的范围划得太宽——「把整个函数包起来,反正不崩就行」。这是一个会积累成灾难的习惯,原因有两条:
- 你会把「本来该暴露出来的 bug」一起吞掉。包裹范围越宽,越可能把「你自己写错了变量名」这种错误也变成一句安静的日志,于是程序带着错误的状态继续跑下去,做出更奇怪的事。
- 你会失去堆栈里最关键的部分。错误发生的位置离
try越远,你越难看出真正的原因。
正确的划法是:try 里只放「你预期它可能失败、而且你知道失败了该怎么办」的那几行。看 bot.js 里两个正面例子。第一个是消息解析(第 475–481 行):
ws.on("message", (raw) => {
let data;
try {
data = JSON.parse(raw.toString());
} catch {
return; // 解析不了就当成没关系的事件丢掉
}
...
});
▲ 注意这里连 (e) 都没写——因为作者确实不需要那个错误对象:他的处理方式是「丢掉这一条,继续等下一个」。这是一个值得学的写法:catch 后面不写参数是合法的,而且它向读者传达了一个信息:「我知道这里会失败,而且我不打算区分失败的原因。」另外注意 try 的范围只有一行——这正是重点。
第二个例子是配置热重载(第 120–127 行):
try {
cfg = loadConfig();
llm.reloadKey();
log(`♻️ config.json 已重新加载 | AI: ${llm.status}`);
} catch (e) {
log("❌ config.json 解析失败,继续使用旧配置:", e.message);
}
▲ 这里的 catch 里有真正的处理:它不只是不崩,它还做了一个决策——保留旧配置继续跑,并且把原因写进日志。这就是「能处理」和「只是不想崩」的区别。cfg 是 let 声明的(第 54 行 let cfg = loadConfig();),所以这里能重新赋值;如果它是 const,这一行会直接报错——你现在可以回头解释第 3 节那条「const 锁住的是名字」的规则了。
11.3 什么时候不该 catch
这是本节最重要的一段。如果你 catch 了却做不了任何有意义的事,那就不要 catch。让它崩,让错误出现在你眼前。
// ❌ 最糟的写法:吞掉一切,什么线索都不留
try {
doSomethingImportant();
} catch (e) {
// 空的
}
// ❌ 也很糟:把错误转成一句没人看得懂的话
try {
doSomethingImportant();
} catch (e) {
console.log("出错了");
}
// ❌ 第三糟:catch 了之后假装正常继续跑
try {
const user = JSON.parse(rawUserData);
} catch (e) {
// 忘了给 user 赋值,后面 user.name 又是一次崩溃
}
给你三条判断标准,遇到 catch 就过一遍:
一、我能不能做一件有意义的事?(换一条路、用默认值、重试、给用户一句人话、写一条日志然后退出。)能,就 catch。
二、这个错误是「可预期的外部问题」,还是「我自己写错了」?网络断了、JSON 格式不对、文件不存在——这是前者,该处理。变量名拼错、函数参数写反——这是后者,catch 它等于把 bug 藏起来,绝对不要。
三、如果我什么都不做,谁会疼?如果答案是「用户会看到一句莫名其妙的失败」,那你就该 catch 并给出一句人话。如果答案是「我自己在开发时多看了两秒报错」,那就别 catch。
还有一个非常容易忽略的规则:catch 里的代码自己也可能出错。如果你在 catch 里又调用了一个可能抛错的函数,那么真正的错误会被这个新错误盖掉,你会拿着一条完全无关的报错查半天。所以最佳实践是:catch 里只做最朴素的事——打印、赋值、返回。
11.4 真实报错,一步步定位
现在做一次完整的实战。假设你在改 bot.js,想给关键词回复加上「发消息的人是谁」这个信息,于是你写了这样一行(这是你自己写错的版本,不是原文):
const isGroup = event.message_type === "group";
const nick = event.sender.nickname; // 你把 ?. 去掉了,想当然地认为 sender 一定有
log(`📩 ${nick}: ${text}`);
运行之后,终端上出现(下面这段是我在本机实跑同一条错误语句得到的真实输出;文件路径、行号、列号都会随你本地文件的内容变化,请以你屏幕上那一份为准):
TypeError: Cannot read properties of undefined (reading 'nickname')
at onEvent (file:///C:/qq-bot/bot/bot.js:355:31)
at WebSocket.<anonymous> (file:///C:/qq-bot/bot/bot.js:501:24)
at WebSocket.emit (node:events:517:28)
at Receiver.receiverOnMessage (node:internal/ws/lib/websocket.js:1186:20)
at Receiver.emit (node:events:363:4)
at Receiver.dataMessage (node:internal/ws/lib/receiver.js:539:14)
at Receiver.getData (node:internal/ws/lib/receiver.js:491:25)
at Receiver.startLoop (node:internal/ws/lib/receiver.js:370:16)
at Receiver._write (node:internal/ws/lib/receiver.js:362:9)
at writeOrBuffer (node:internal/streams/writable.js:378:5)
... 还有十几行
▲ 那份真实输入长这样:TypeError: Cannot read properties of undefined (reading 'nickname') 后面跟一串 at ...。要学的不是记住某个行号,而是「第一行结论 + 堆栈里第一处属于自己代码的路径」这个结构。顺便说一个真实情况:bot.js 和 llm.mjs 里实际上没有这种会抛错的写法——bot.js 第 354 行是 const nickname = event.sender?.card || event.sender?.nickname || "";,用了 ?.;llm.mjs 第 352 行是 j?.choices?.[0]?.message?.content ?? "",一路可选链到底。所以下面这个错误是「你把 ?. 去掉之后」才会出现的——而这正是我们接下来要练的定位过程。
现在按四步走,不要从上往下读:
第一步:先读输出里最上面那一行结论。也就是 TypeError: Cannot read properties of undefined (reading 'nickname') 这一句——报错类型加说明。翻译:「我试图从一个 undefined 上读 nickname。」为什么是最上面而不是最下面?因为在 Node 的堆栈里,错误本身在最上面,「它是怎么被调用过来的」在下面往下排。而新手看一大段红色输出时,眼睛容易先落在最后一行——那一行通常是库的底层实现,信息量最低。(这跟很多语言的异常链顺序不一样,别靠记忆,靠每次先看第一行。)
第二步:从堆栈里找第一处属于你自己代码的路径。最上面那一行 at ... 就是:at onEvent (file:///C:/qq-bot/bot/bot.js:355:31)。bot.js 是你写的文件,355 是行号,31 是那一行里出错语句大概的列位置。关键不是记住 355 这个数字,而是学会「在一堆 at 里认出哪个文件名是你自己的」——后面那些 node:internal/ws/...、node:events 是库和 Node 内部的代码,现在全部忽略,它们只是告诉你「这个事件是从 WebSocket 消息里来的」,与你的 bug 无关。
第三步:去那一行,找出「谁是 undefined」。打开你本地那个文件的对应行,你会看到 const nick = event.sender.nickname;。报错说的是「读 nickname 时左边是 undefined」,所以左边那个东西——event.sender——是 undefined。那它为什么是 undefined?往上看:event 是从参数来的,就是那条消息事件;event.sender 不存在,说明这条事件里没有 sender 键。
第四步:确认,然后修,最后记录。在这一行上面加一句 console.log(Object.keys(event));,跑一次,你会看到键的列表里确实没有 sender。什么消息会没有 sender?可能是某个协议版本、可能是系统消息、也可能是一条格式不全的事件——不管哪种,你的代码都不该因此崩掉。修法就是 bot.js 原本的写法:
const nick = event.sender?.nickname || String(event.user_id);
最后一步,也是最容易被跳过的一步:用一句话把原因写进你的笔记。比如:「event.sender 不是每条消息都有,凡是从外面进来的对象,读它的键都要用 ?.。」——写下来之后,这个错误在你这辈子里最多再犯两次。
技巧新手读堆栈最常见的错误是从上往下一行行读,然后被 node:internal/... 吓住,觉得「这门语言好复杂」。请记住这个顺序:先看第一行的结论,再在堆栈里找自己的文件名,其余全部无视。堆栈是给你定位用的,不是给你通读的。
11.5 对「外部输入」保持不信任
这一节最后给你一条会影响你一生写代码方式的原则:凡是来自程序外部的数据,默认它是坏的。外部包括:网络请求、文件、用户输入、环境变量、数据库里别人写进去的内容。
回到那个 JSON.parse 的例子。bot.js 对它做了两层防御(第 29–32 行和第 477–481 行):
// 第一层:读配置文件时,顺手剥掉 BOM,再解析
function readJson(file) {
const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(raw);
}
// 第二层:解析协议端推来的原始数据,失败就丢掉这一条
let data;
try {
data = JSON.parse(raw.toString());
} catch {
return;
}
为什么这两处的处理方式不一样?这是一个非常好的问题,答案在于「失败的代价」:
- 配置文件解析失败 → 让错误抛出去。因为配置文件是你自己写的,解析不了说明你写错了,而「启动时立刻崩掉并告诉你哪里错了」正是你想要的。安静地带着默认配置启动,反而会让你以为配置生效了。
- 协议端推来的数据解析失败 → 丢掉这一条,继续跑。因为那条数据的来源不受你控制,一条坏数据不该让 24 小时在线的机器人下线。这不是「同一个问题两种答案」,而是根据后果做了不同的取舍。
注意 readJson 里那个静默的替换。它把 BOM 悄悄删掉了——这好不好?从「让机器人能起来」的角度是好的;从「让你知道文件编码有问题」的角度是不好的,因为问题被藏起来了。在实践中,这种「边界处的静默修复」是可以接受的,前提是你知道自己在修什么,并且它修的是一个确定无害的东西(BOM 没有任何语义)。如果换成「把解析失败的 JSON 换成空对象」,那就是灾难了。
想一想现在请你设计 bot.js 里「关键词规则」的输入校验(在 loadConfig 里做)。你需要判断每一条 keywords 是不是合法,至少要处理这几种情况:match 字段缺失、mode 是没见过的字符串、reply 是数字而不是字符串。请你决定:遇到坏规则时,是「丢掉这一条、打印警告」还是「整个启动失败」?为什么?把你的理由写成一句话,然后问自己:如果坏规则是「正则模式写错了」,你的答案会变吗?(提示:正则在配置加载时检查,和等到消息到来时才 new RegExp,两者的失败时机差了十万八千里。)
12. 模块化:为什么 bot.js 要拆出一个 llm.mjs
你现在已经能读一个几百行的文件了。但下一个问题是:如果它变成两千行呢?这一节讲的是「怎么把代码分成几个文件」,以及划分的标准。
12.1 import 与 export:两个文件怎么互相看见
一个文件里的东西默认是私有的,别的文件看不见。要让别人能用,你需要 export;要使用别人的东西,你需要 import。
// ---------- tools.mjs ----------
export function render(text, vars) { // 命名导出:可以导出很多个
return String(text).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
}
export const BOT_NAME = "OWL";
// ---------- main.mjs ----------
import { render, BOT_NAME } from "./tools.mjs"; // 用花括号按名字取
console.log(render("我是 {botName}", { botName: BOT_NAME }));
▲ 注意三点:(1)路径 "./tools.mjs" 前面的 ./ 不能省,表示「和当前文件同一个目录」;(2)文件名带扩展名(在 Node 的 ES 模块里必须写全);(3)import 是静态的——它必须写在文件最上面,不能写在 if 里或函数里。这个限制是有意的:它让 Node 在运行之前就能把整个依赖关系理清楚。
还有默认导出(export default),一个文件只能有一个,导入时不需要花括号:
// ---------- llm.mjs(结尾处)----------
export class LlmClient { ... }
// 或 export default class LlmClient { ... }
bot.js 里是怎么用的?第 10–15 行:
import fs from "node:fs";
import path from "node:path";
import http from "node:http";
import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws";
import { LlmClient } from "./llm.mjs";
▲ 前四行是 Node 自带的模块(node: 前缀是官方推荐写法),第五行是第三方库 ws(装在 node_modules 里,所以不用写路径),最后一行是项目自己的文件。注意最后一行带花括号、带 ./、带扩展名——三个特征让它一眼就能和库区分开。
12.2 为什么把模型调用拆出去
llm.mjs 里装的是什么?看它负责的事情:读取 API Key(有多级优先级)、拼系统提示词、维护每个会话的历史、做输入长度限制、按人/按群做频率限制、发 HTTPS 请求、45 秒超时、清理模型输出、识别危机等级、记忆的读写与持久化。
要点这里顺便给你一个会反复用到的重要区分:代码里的默认值,不等于线上正在用的值。比如 llm.mjs 在没读到配置时会用 maxTurns ?? 6 和 ttlMinutes ?? 60(最近 6 轮对话、记 60 分钟);而 OWL 服务器上真实的 bot/config.json 里写的是 8 / 180(8 轮、3 小时)。所以当你想知道「她到底记得多少」时,答案在配置文件里,不在代码里——代码只负责「配置没写的时候用什么兜底」。
这也是 ?? 用在这里的原因:它表达的正是「只有确实没配置时才兜底」。如果你把默认值当成事实去推理,就会得出一个和线上行为不一样的结论——这类误判在排查问题时非常常见,而且极难发现,因为代码看起来完全支持你的结论。
这些事和「收一条 QQ 消息、决定回什么」有什么关系?几乎没有关系。这正是拆分的第一条理由:
拆分的标准是「职责」,不是「行数」。
bot.js 的职责是:跟协议端说话——收事件、判断该怎么处理、把回复发回去。llm.mjs 的职责是:跟大模型说话——把一段文本变成另一段文本,并管好这件事的配额、超时和记忆。这两件事会因为完全不同的原因被修改:换协议端(NapCat 换成别的)只动前者;换模型供应商(DeepSeek 换成别家)只动后者。这就是拆分的判据。
我给你一个比「行数」好得多的判断标准:问自己「这些东西会因为什么原因而变化」。如果两组代码的变化原因不一样,它们就该在不同的文件里。这条原则在软件工程里叫「单一职责」,听起来像口号,但它的检验方法非常具体:
- 「我想把新闻里那个
deepseek-chat换成另一个模型」——要动几个文件?如果答案是一个,说明拆对了。 - 「我想把「@她」的判定规则改一下」——要动几个文件?如果答案也是一个,而且这个文件和上面那个不是同一个,那这个拆分就非常健康。
反过来,如果两个文件里各有一半逻辑都叫「处理消息」,那它们其实是耦合的:你改一处,另一处也要跟着改,而且你经常忘了它。这种状态比一个长文件更糟。
12.3 拆分带来的代价,也要认
模块化不是免费的。它有两个代价,你现在就该知道:
- 读代码要跳文件了。以前从上往下读一遍就懂,现在你要在编辑器里 Ctrl+点击跳过去再跳回来。这也是为什么拆分要按「职责」而不是按「把大文件切成三个小文件」——如果拆得没有道理,你就只是在制造跳转。
- 边界上的东西要显式传递。
llm.mjs不知道cfg是什么,所以bot.js在构造它的时候传了一个函数进去(第 84 行):
const llm = new LlmClient({ botDir: __dirname, getConfig: () => cfg, log });
▲ 这一行很值得玩味。它没有把 cfg 本身传进去,而是传了一个能取到 cfg 的函数 () => cfg。为什么?因为 cfg 是会被热重载替换掉的(cfg = loadConfig())。如果当初传的是 cfg 的值,那么配置一改,llm 手里还是旧的那个对象,热重载对它就不生效了。传「取值的方法」而不是「取到的值」,是一种非常常见、也非常有用的技巧——它让两边在时间上解耦。你会在第八章看到它的另一个名字:「惰性求值」。
还有一点:bot.js 里用的扩展名是 .js,而 llm.mjs 用的是 .mjs。这不是随意的。.mjs 明确告诉 Node「这个文件是 ES 模块」,而 .js 的含义要看 package.json 里有没有 "type": "module"。这个坑我们会在第五章讲 npm 的时候讲清楚——现在你只需要记住:看到 .mjs 就知道它是模块,看到 import 语句就知道这个文件依赖别的文件。
最后给你一个关于「什么时候拆」的经验判断:
不要一开始就拆。先把功能写在一个文件里跑通,然后观察:哪几个函数总是一起被改?哪一块代码你每次读都要先跳过?哪一个概念已经有了自己的名字(比如「模型调用」「安全兜底」「数据存储」)?当你发现某一个概念已经把它的邻居挤得看不清时,就是拆的时候。提前拆分会让你反复调整边界,而边界只有在你有真实代码之后才看得清。
13. 动手项目:写一个「离线消息处理器」
现在到了这一章最重要的一步。前面十二节给了你零件,这一节要你用它们造一台能动的机器。
为什么要做「离线」的版本?因为真实的机器人需要:一台服务器、一个 QQ 账号、一个协议端、一个 API Key,还涉及账号风控——这些门槛会让你在写第一行业务逻辑之前就耗光耐心。而这个项目不需要联网、不需要异步、不需要服务器:你在自己的笔记本上敲 node offline-bot.mjs,一秒之内就能看到「我写的逻辑真的按我想的方式在跑」。
它的功能是:给它一个假的 OneBot 事件对象,它回答你「这条消息该回什么,以及为什么」。它包含指令匹配、关键词匹配(三种模式)、@机器人判定、以及冷却。骨架全部来自 bot.js,但被大幅简化——我砍掉了异步、网络、模型、记忆,只留下纯粹的逻辑。
13.1 第一步:造一个假事件(先造数据,再写逻辑)
新手写程序最常见的错误是从「逻辑」开始写。正确的顺序是先决定数据的形状——因为你的每一个函数都在处理数据,数据长什么样决定了函数长什么样。
我们要处理的输入,就是一个 OneBot v11 的事件对象(第四章会告诉你它每一个字段的真实含义,现在照抄形状就够了):
{
post_type: "message", // 事件大类:message / notice / request / meta_event
message_type: "group", // group(群聊)或 private(私聊)
group_id: 123456, // 群号,私聊时没有这个键
user_id: 10001, // 发消息的人的 QQ 号
self_id: 1876148307, // 机器人自己的 QQ 号
message_id: 9001, // 这条消息的编号(用于引用回复)
sender: { card: "小明", nickname: "xiaoming" },
message: [ // 消息段数组:一条消息由若干段组成
{ type: "at", data: { qq: "1876148307" } },
{ type: "text", data: { text: " /ping" } }
]
}
▲ 请你现在就把这个对象抄进一个新文件 offline-bot.mjs 里,作为 events 数组的第一个元素。不要复制我下面给的完整代码然后跑通就完事——那样你只是又读了一遍代码。这一节的学习方式是:读一小段、自己写一小段、跑一次、看输出对不对。看不懂的部分再往下读。
13.2 第二步:配置(让它可调,而不是写死)
const cfg = {
botName: "OWL",
selfId: "1876148307",
prefix: "/",
cooldownMs: 1200, // 同一会话里的普通回复间隔
atCooldownMs: 8000, // @应答 / 私聊兜底的间隔(更宽松,避免刷屏)
maxKeywordReplies: 2, // 一条消息最多命中几条关键词
commands: [
{ command: "ping", reply: "pong 🏓" },
{ command: "time", reply: "现在是 {time}" },
{ command: "help", reply: "我是 {botName},支持 /ping /time /help" }
],
keywords: [
{ match: "你好", mode: "contains", reply: "你好呀,我是 {botName}" },
{ match: "帮助", mode: "exact", reply: "输入 /help 看指令列表" },
{ match: "我.*累", mode: "regex", reply: "{nick},先歇一会儿吧" },
{ match: "谢谢", mode: "contains", reply: "不客气~" }
],
atReplies: ["我在的,怎么啦?", "收到你的召唤,请讲~"],
privateReplies: ["私聊收到啦,我是 {botName}。"]
};
▲ 逐行读:为什么它是一个对象而不是散落的一堆变量?因为「配置」是一个整体,你以后可以把它从 config.json 读进来(就像 bot.js 那样),而代码的其他部分一行都不用改。为什么 cooldownMs 和 atCooldownMs 是两个不同的值?因为这两种回复的性质不同:关键词回复是在「接话」,需要跟得上节奏;而 @ 应付和私聊兜底是「我在」,太频繁会显得像个复读机。配置项的设计本身就是产品判断。
13.3 第三步:五个工具函数
这五个函数你都在本章见过,现在自己再写一遍。它们是纯函数(除了最后一个),可以单独测试:
function pad2(n) {
return String(n).padStart(2, "0");
}
function nowText(date) {
const d = date || new Date();
return (
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
`${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
);
}
function pick(list) {
return list[Math.floor(Math.random() * list.length)];
}
function render(text, vars) {
return String(text).replace(/\{(\w+)\}/g, (whole, key) => {
return key in vars ? vars[key] : whole;
});
}
四个函数里最值得看的是 pick,因为它藏着一个容易忽略的概率问题。
Math.random() 返回一个大于等于 0、小于 1 的小数,比如 0.7341。乘上数组长度 2,得到 1.4682;Math.floor 向下取整,得到 1。这就取到了下标 1。
现在请你回答一个问题:为什么是 Math.floor(x * len),而不是 Math.round(x * len)?自己推一遍:Math.random() 的取值范围是 [0, 1)——不含 1。如果长度是 3:
- 用
floor:x * 3落在[0, 3),取整后是0、1、2——三个下标各占三分之一,完美。 - 用
round:x * 3落在[0, 3);四舍五入之后,0只覆盖[0, 0.5),1覆盖[0.5, 1.5),2覆盖[1.5, 2.5)——而[2.5, 3)这一段会舍成3,那是一个不存在的下标,会取到undefined。
▲ 这就是「差一错误」的又一形态。它不报错(undefined 会被塞进回复里变成字符串 "undefined",或者触发前面那个 Cannot read properties),而且只在「随机数恰好落在最后那 1/6」时才出现——一个每天只错几次、看起来像幽灵的 bug。现在你知道它从哪来了。
接下来是消息段的转换和 @ 判定。这两个函数直接来自 bot.js,我们已经在 8.4 和 8.5 节拆过:
function segmentsToText(segments) {
if (typeof segments === "string") return segments;
if (!Array.isArray(segments)) return "";
return segments
.map((s) => {
switch (s.type) {
case "text":
return s.data?.text ?? "";
case "image":
return "[图片]";
case "face":
return "[表情]";
case "at":
return `@${s.data?.qq ?? ""}`;
default:
return `[${s.type}]`;
}
})
.join("");
}
function isAtBot(event) {
const selfId = String(event.self_id ?? "");
const segs = Array.isArray(event.message) ? event.message : [];
return segs.some((s) => s.type === "at" && String(s.data?.qq) === selfId);
}
function stripAt(text) {
return String(text)
.replace(/<@!?\d+>/g, "")
.replace(/@[\w-]{3,15}\s*/g, "")
.trim();
}
▲ 注意我把 segmentsToText 里 case "reply" 那一支去掉了——因为我们的离线版本根本不会收到引用段。这不是"偷懒",而是「按需实现」:每一行代码都应该是为了一个真实存在的需求而写的。等你真的需要处理引用时再加,那时你会同时加上对应的测试。
13.4 第四步:冷却,以及它为什么需要一个 Map
const lastReplyAt = new Map();
function throttled(key, windowMs, now) {
const last = lastReplyAt.get(key) ?? 0;
if (now - last < windowMs) return true;
lastReplyAt.set(key, now);
return false;
}
▲ 和 bot.js 的版本只差一处:这里多了一个 now 参数。为什么?因为真实版本用 Date.now() 自己取当前时间,而那样会让这个函数「不纯」——同一个输入,在不同时刻调用会得到不同结果。加了 now 参数之后,它就变成了纯函数:你可以用固定的时间点写测试,比如「第 1000 毫秒和第 1500 毫秒之间应该被拦下」。这个改动就是我前面说的「把不纯的部分赶到边缘去」:时间由调用者给,函数本身只管逻辑。
为什么必须用 Map 而不是一个普通对象 {}?两个原因,第二个更重要:
- 键的类型自由。这里我们用的键是
"group:123456"这样的字符串,其实对象也能存。但想象键是数字或对象的时候,对象就不行了(对象的键会被强制转成字符串)。 - 普通对象继承了一堆内置的键。你写
const m = {},m.toString已经存在了,m.constructor也存在。如果某个用户的 ID 恰好叫"toString",你的冷却判断会拿到一个函数而不是时间戳,然后做出完全错误的行为。Map没有这个问题——它是一张干净的表。
现在有个问题值得你想一想:冷却的键为什么是 "group:123456" 这样的字符串,而不是直接 123456?因为如果我们只用群号当键,那么「群 123456 的关键词冷却」和「群 123456 的 @ 冷却」会互相撞车——@ 一次之后,关键词回复也被拦掉了。bot.js 里的做法更细(kw:${groupId}、at:${groupId}、pv:${userId}),给每一类限流一个独立的命名空间。「给状态起一个能唯一标识它的名字」这件事,是你在写任何有状态的逻辑时都要先做的一步。
13.5 第五步:组装变量与三个匹配函数
function varsFor(event, text) {
const nick = event.sender?.card || event.sender?.nickname || String(event.user_id);
return {
botName: cfg.botName,
time: nowText(),
nick,
userId: String(event.user_id ?? ""),
groupId: String(event.group_id ?? ""),
text
};
}
function matchCommand(event, text) {
const clean = stripAt(text);
const prefix = cfg.prefix || "/";
const low = clean.toLowerCase();
for (const c of cfg.commands) {
const full = `${prefix}${c.command}`.toLowerCase();
if (low === full || low === String(c.command).toLowerCase()) {
return render(c.reply, varsFor(event, clean));
}
}
return null;
}
▲ matchCommand 逐行读:stripAt(text) 先去掉 @,因为「@她 /ping」和「/ping」应该是同一个指令。cfg.prefix || "/" 是一个默认值兜底(这里用 || 而不是 ?? 更稳:如果有人把 prefix 设成空字符串想关掉前缀,这里会强制回到 "/"——注意这就是我们在第 4 节说的那个「|| 和 ?? 语义不同」的真实影响点。这里选 || 是对的吗?你自己判断。)toLowerCase() 让 /PING 也能命中,这是对用户的宽容。最后那个 || low === String(c.command).toLowerCase() 允许用户不带斜杠直接打 ping——这是一条产品决策,写在了逻辑里:她希望用户少打一个字符。
接着是关键词和「挑出所有正则规则」:
function matchKeywords(event, text) {
const clean = stripAt(text);
const hits = [];
for (const k of cfg.keywords) {
const needle = String(k.match);
const matched =
k.mode === "exact"
? clean === needle
: k.mode === "regex"
? new RegExp(needle, "i").test(clean)
: clean.includes(needle);
if (matched) hits.push(k);
if (hits.length >= cfg.maxKeywordReplies) break;
}
return hits;
}
function regexRules(rules) {
return rules.filter((k) => k.mode === "regex");
}
▲ 注意 matchKeywords 返回的是规则对象的数组,而不是回复文本的数组。为什么?因为「哪条规则命中了」这个信息在别处还有用(比如写日志、统计哪条规则最常被触发)。让函数返回「信息」而不是「结果」,是让代码更好扩展的一个常用手法。渲染成文本那一步留给调用者去做。
另外注意 break:一旦命中的条数达到上限就停下。它防止了一种真实情况——一句话里包含「你好」和「谢谢」和「测试」,于是机器人一口气回三句话,看起来像刷屏。上限本身就是产品设计。
13.6 第六步:主入口,四层判断
function handleEvent(event, now) {
if (event.post_type !== "message") return { reply: null, why: "不是消息事件" };
const text = segmentsToText(event.message);
const isGroup = event.message_type === "group";
const clean = stripAt(text);
const key = isGroup ? `group:${event.group_id}` : `private:${event.user_id}`;
// 第一层:指令最优先,永远不被后面的规则抢走
const cmd = matchCommand(event, text);
if (cmd) {
if (throttled(`cmd:${key}`, cfg.cooldownMs, now)) {
return { reply: null, why: "指令命中,但还在冷却期内" };
}
return { reply: cmd, why: "指令" };
}
// 第二层:关键词
const hits = matchKeywords(event, text);
if (hits.length) {
if (throttled(`kw:${key}`, cfg.cooldownMs, now)) {
return { reply: null, why: "关键词命中,但还在冷却期内" };
}
const body = hits.map((k) => render(k.reply, varsFor(event, clean))).join("\n");
return { reply: body, why: "关键词" };
}
// 第三层:群里 @ 机器人
if (isGroup && isAtBot(event)) {
if (throttled(`at:${event.group_id}`, cfg.atCooldownMs, now)) {
return { reply: null, why: "@ 命中,但还在冷却期内" };
}
return { reply: render(pick(cfg.atReplies), varsFor(event, clean)), why: "@应答" };
}
// 第四层:私聊兜底
if (!isGroup) {
if (throttled(`pv:${event.user_id}`, cfg.atCooldownMs, now)) {
return { reply: null, why: "私聊兜底,但还在冷却期内" };
}
return { reply: render(pick(cfg.privateReplies), varsFor(event, clean)), why: "私聊兜底" };
}
return { reply: null, why: "没有规则命中" };
}
▲ 逐段读,注意四件事:
- 函数的返回值是一个对象
{ reply, why },而不是一个字符串。这是本章最重要的一条设计示范。如果只返回字符串,那么「不回复」就只能用空字符串表示,你就分不清「决定不回复」和「回复内容是空」这两种情况;而且你永远不知道为什么不回复。why字段的价值在于:它让程序能解释自己。你以后写任何有分支的逻辑时,都该留一个why——它同时是最好的日志、最好的调试工具、和最好的测试断言对象。 now是参数。整个函数因此是纯的:给它同一批事件和同一组时间戳,它永远给出同样的结果。你可以在没有服务器、没有 QQ、没有网络的情况下完整验证它。- 每一层的冷却键都不一样(
cmd:/kw:/at:/pv:)。你如果把四个throttled的 key 写成同一个,那么群里的关键词回复会把你 @ 她的机会也吃掉,你会以为「@ 她不怎么理我」。 - 顺序是有意的。指令最前(写死的命令不能被概率性的东西影响);关键词第二(它是主动触发的);@ 第三(被动触发);私聊兜底最后(它其实是无条件兜底)。把「无条件兜底」放在最后的另一个好处是:你永远不会在它后面加逻辑,因为后面已经没有了。
13.7 第七步:跑起来,看它说什么
const events = [
// 1. 群里 @她 然后 /ping —— 应该命中指令
{ post_type: "message", message_type: "group", group_id: 123456, user_id: 10001,
self_id: 1876148307, message_id: 9001,
sender: { card: "小明", nickname: "xiaoming" },
message: [ { type: "at", data: { qq: "1876148307" } },
{ type: "text", data: { text: " /ping" } } ] },
// 2. 群里 @她 然后说「你好呀」—— 应该命中关键词
{ post_type: "message", message_type: "group", group_id: 123456, user_id: 10002,
self_id: 1876148307, message_id: 9002,
sender: { nickname: "阿泽" },
message: [ { type: "at", data: { qq: "1876148307" } },
{ type: "text", data: { text: " 你好呀" } } ] },
// 3. 群里没 @她,说「我最近好累」—— 应该命中正则关键词
{ post_type: "message", message_type: "group", group_id: 123456, user_id: 10003,
self_id: 1876148307, message_id: 9003,
sender: { card: "小雨" },
message: [ { type: "text", data: { text: "我最近好累" } } ] },
// 4. 私聊「在吗」—— 应该走私聊兜底
{ post_type: "message", message_type: "private", user_id: 10004,
self_id: 1876148307, message_id: 9004,
sender: { nickname: "小舟" },
message: [ { type: "text", data: { text: "在吗" } } ] },
// 5. 又是 1 号小明 @她 /ping,但时间上只隔了 0 毫秒 —— 应该被冷却拦下
{ post_type: "message", message_type: "group", group_id: 123456, user_id: 10001,
self_id: 1876148307, message_id: 9005,
sender: { card: "小明" },
message: [ { type: "at", data: { qq: "1876148307" } },
{ type: "text", data: { text: " /ping" } } ] },
// 6. 一个非消息事件 —— 应该在第一行就被挡回去
{ post_type: "notice", notice_type: "group_recall" }
];
const t0 = 1700000000000; // 固定起点,让结果可复现
events.forEach((event, i) => {
// 第 5 条故意和第 1 条落在同一毫秒,用来看冷却到底管不管用
const now = i === 4 ? t0 : t0 + i * 5000;
const r = handleEvent(event, now);
const who = event.post_type === "message"
? `${event.message_type}/${event.user_id}`
: "非消息";
if (r.reply === null) {
console.log(`#${i + 1} [${who}] 不回复 —— ${r.why}`);
} else {
console.log(`#${i + 1} [${who}] (${r.why}) -> ${r.reply}`);
}
});
运行 node offline-bot.mjs,你会看到(#3 里的「小雨」取决于 sender.card,稳定可见):
#1 [group/10001] (指令) -> pong 🏓
#2 [group/10002] (关键词) -> 你好呀,我是 OWL
#3 [group/10003] (关键词) -> 小雨,先歇一会儿吧
#4 [private/10004] (私聊兜底) -> 私聊收到啦,我是 OWL。
#5 [group/10001] 不回复 —— 指令命中,但还在冷却期内
#6 [非消息] 不回复 —— 不是消息事件
现在请停下来,认真看这份输出。这六行里有你这一整章学到的东西:第 1 行证明「数组套对象」被正确读成了文本并命中了指令;第 3 行证明正则规则在工作,而且 {nick} 被替换成了群名片;第 5 行证明状态(那个 Map)真的跨调用活了下来;第 6 行证明最外层的守卫在起作用。
▲ 第 5 行尤其值得你盯着看一会儿。它不是"没匹配到",它是"匹配到了但被拦下了"——所以 why 里写着完整的原因。如果当初 handleEvent 只返回字符串,这一行输出就只能是「不回复」,而你会在半年后对着这个现象怀疑是不是正则写错了。
13.8 第八步:自己动手验证边界
跑通不算完成。现在请在文件末尾加上这几行,亲眼确认你自己在第 9 节学的东西:
console.log("--- 只挑正则规则 ---");
console.log(regexRules(cfg.keywords).map((k) => k.match).join(" , "));
// 输出:我.*累
console.log("--- stripAt 的边界 ---");
console.log(JSON.stringify(stripAt("@1876148307 你好"))); // "你好"
console.log(JSON.stringify(stripAt("@1876148307你好"))); // "你好"
console.log(JSON.stringify(stripAt("<@!123> 你好"))); // "你好"
console.log(JSON.stringify(stripAt("我最近好累"))); // "我最近好累"
▲ JSON.stringify 在这里的作用是:把字符串两端的空白和不可见字符显示出来。如果直接 console.log("你好 "),你根本看不出后面有个空格;JSON.stringify 会给你带引号的 "你好 "。这是调试字符串时最常用的一招。
然后请你自己做三件事(这才是这个项目真正的价值):
- 给
keywords加一条会坏的正则(比如{ match: "[未闭合", mode: "regex", reply: "?" }),跑一次,看看会发生什么。它会在哪一行报错?报错信息是什么?(这里你还没有try/catch,所以会看到完整的堆栈——正好拿来练第 11 节的四步定位法。)然后加上第 6.3 节那个「配置体检」的try/catch,让它在加载时就告诉你哪条规则坏了。 - 把
atCooldownMs改成 1000,把第 5 条的now改到t0 + 2000,看冷却放行之后的行为变化。 - 加一条新指令
/help之外的/count,让它回答「这次运行里我一共回复了几条消息」。这条练习会逼你面对一个问题:计数器这个状态该放在哪里?(提示:它和lastReplyAt一样,需要活在函数调用之外。这就是第 10 节说的「闭包与状态」的实战。)
想一想现在回头看这个项目,请回答一个设计问题:如果我要给私聊也加上「指令冷却」和「关键词冷却」的区分,需要改哪些地方?接着再想:目前的 key 是 group:${group_id} 或 private:${user_id},而冷却键是在它前面加前缀拼出来的。这个拼法有没有隐患?(提示:假如有一个群号是 123,另有一个用户的 ID 是 123——它们拼出来的键会不会撞车?group:123 和 private:123 呢?再想想,如果 key 的拼法改成 ${type}-${id},会不会更安全?)
最后,我要在这个项目上再加一句诚实的说明:这个处理器还不是 OWL。它缺少的东西至少有五样,而这五样正好是后面几章的内容:它不知道消息从哪来(第四章的网络协议)、它不能等(第五章的异步)、它记不住任何东西(第六章的数据库)、它跑在你笔记本上(第七章的部署)、它不会说话(第八章的模型)。但它的骨架是真的——bot.js 里那个 onEvent 的四层结构,和你刚才亲手写的这个,是同一种形状。
自查:你是不是真的懂了
先自己回答,再展开参考答案。凡是你只能靠「感觉」回答的,说明还要再读一遍。其中第 2、9 题是「这段代码为什么这么写」;第 5、8 题是排查题;第 6、10 题是动手题;第 3 题是辨析题;第 7 题是开放题。全部题目都要求你给出判断依据,而不只是结论。
-
请用「数据 + 操作 + 顺序」三个词,解释
bot.js的onEvent为什么把指令判断放在最前面、把 AI 调用放在最后。参考答案
数据是那条
event(以及由它算出的text、clean、sessionKey);操作是「匹配指令」「判断要不要交给 AI」「走静态规则」「发回复」;顺序是「指令 → AI → 静态兜底」。之所以这样排,是因为越靠前的判断越便宜、越确定:指令就是几次字符串比较,几乎不可能失败;AI 调用要花一秒、要花钱、会因为网络和配额失败。把便宜可靠的放前面,可以让「贵的步骤」只在真正必要时发生;把指令放最前,还能保证「删除记忆」这种动作永远不会被模型的随机输出影响。如果你答的是「因为作者想这样写」,那就等于没答。 -
render里写的是(k in vars ? vars[k] : m),也就是「找不到占位符就原样保留」。如果改成vars[k] ?? ""(找不到就用空字符串),会带来什么后果?请从「排障」的角度说明为什么原版更好。参考答案
改成空字符串之后,一条把
{time}写错成{tiem}的配置,回复会变成「现在是 」——用户只会觉得机器人有点傻,而你拿不到任何线索:日志里记录的也是「现在是 」,看起来完全正常。原版会输出「现在是 {tiem}」,那条花括号就是一枚警报,任何人(包括你自己)一眼就知道配置和变量对不上了。这条规则可以推广成一句:宁可让错误以「看起来不对」的形式出现,也不要让它伪装成正常输出。同一个思想在readJson的 BOM 处理、在segmentsToText的default分支里都出现过。 -
isAtBot里写的是String(s.data?.qq) === selfId,其中selfId也是String(event.self_id ?? "")。请说明:如果去掉两边的String(),在什么情况下会出错?如果去掉?? ""呢?参考答案
去掉
String():OneBot 的事件里self_id通常是数字,而消息段里的qq有时是字符串(不同协议端行为不一致)。数字1876148307和字符串"1876148307"用===比较永远为 false,于是机器人完全不认自己被 @ 了——而且不报错、不写日志,现象只是「@ 她她不理」。去掉?? "":如果self_id缺失,String(undefined)会得到字符串"undefined",一个看起来很正常的值。它仍然不会匹配任何真实 QQ 号,但如果你别处用它做键或写文件,就会产生一个叫"undefined"的用户——垃圾数据混进了正常数据里。判断的关键在于:===不做类型转换,所以「两边类型必须一致」这件事必须在写代码时就保证。 -
segmentsToText里为什么要把图片转成"[图片]"、表情转成"[表情]",而不是直接丢掉?参考答案
因为「用户发了一张图」和「用户什么都没发」是两条不同的消息。如果丢掉,那么一个人只发一张图时,
segmentsToText会返回空字符串"",后面所有逻辑都会把它当成「空消息」处理——比如不回复、或者进入某些把它当作无内容的兜底分支。加一个占位符,信息就不会凭空消失,下游的逻辑能看到「这里有个东西,只是它不是文字」。同类理由也适用于default分支的[${s.type}]:它保证任何未知类型都留下痕迹,而不是变成undefined污染整段文本。 -
你运行自己的
offline-bot.mjs,在群里发了一条「@她 你好」,但机器人没有任何输出,日志里也什么都没有。请写出至少四步排查过程。参考答案
(1)先确认它有没有进到函数里:在
handleEvent第一行打印event.post_type。如果什么都没有,说明事件根本没被传进来(调用处的循环写错了),或者post_type !== "message"提前 return 了。(2)打印中间的text和clean:如果text是"@1876148307 你好"而clean还是带 @ 的,问题在stripAt(比如 QQ 号太短、不满 3 个字符)。(3)检查关键词规则的mode:如果是"exact",那必须整句等于「你好」才命中,而实际消息可能是「你好呀」。(4)检查冷却键:如果这条消息落在一个刚刚回复过的键上,它会走reply: null那条分支——这时候why字段就是答案,把它打印出来。(5)最后检查self_id的类型是否和消息段里的qq一致(第 3 题)。注意第 4 步正是「返回值里带上 why」这个设计的回报:没有它,你只能靠猜;有它,错误自己会说原因。 -
动手题一:用不超过 8 行代码,写一个函数
countByMode(rules),输入一个关键词规则数组,返回一个对象,形如{ contains: 3, exact: 1 }(mode缺失时按"contains"统计)。参考答案
参考实现(用第 6 节的
reduce):function countByMode(rules) { return rules.reduce((acc, k) => { const mode = k.mode ?? "contains"; acc[mode] = (acc[mode] ?? 0) + 1; return acc; }, {}); }评分要点(不是「能不能跑」,而是这几处):(1)
k.mode ?? "contains"而不是k.mode || "contains"——因为mode只可能是字符串,两者今天行为一样,但??表达的语义是「没配置」,更准确;(2)acc[mode] ?? 0处理「第一次遇到这个模式」的情况,忘了它会得到NaN;(3)return acc必须写,忘了它下一圈的acc就是undefined;(4)初始值{}必须给,否则第一圈的acc是数组的第一个元素,类型就错了。如果你用for...of手写循环实现,只要不超过 8 行,同样正确——不要因为reduce看起来高级就用它。 -
有人主张:「既然
bot.js只有 500 多行,全部写在一个文件里最省事,拆模块纯属自找麻烦。」请说出这个说法的合理之处,再指出它在什么条件下会失效。参考答案
合理之处:500 行确实还能一口气读完,而拆分带来「跳文件」和「边界要显式传递参数」两个真实成本(第 12 节)。如果这个项目永远只有一个人维护、永远不换模型供应商、也永远不换协议端,那么不拆是划算的。失效的条件是:变化的原因开始分叉。比如你想换一家模型供应商——这需要改的是「跟模型说话」的那部分;或者 NapCat 换成另一个协议端——这需要改「跟协议端说话」的那部分。如果两者混在一个文件里,每次改动你都得在一个 500 行的文件里挑出相关的那几十行,而且很容易碰坏另一部分。
llm.mjs的存在还有一个具体的好处:它能被单独测试——你可以在不启动 QQ 服务的情况下,直接调用它、传一段假的输入、检查它的输出。 -
排查题:下面这段代码在群里运行时报
Cannot read properties of undefined (reading 'includes'),请指出原因并给出两种修法(一种改数据、一种改代码)。const keywords = cfg.llm.trigger.keywordList; const hit = keywords.some((k) => clean.includes(k));参考答案
原因是
config.json里没有keywordList这个键(或者连trigger都没有),于是keywords是undefined,undefined.some(...)就崩了。注意报错说的是reading 'some'还是'includes',这决定了你该往哪看:如果是'some',说明keywords本身是 undefined,就是这里的问题;如果是'includes',说明clean是 undefined——那是另一个 bug。改数据:在config.json的llm.trigger里补上"keywordList": []。改代码:const keywords = cfg.llm?.trigger?.keywordList ?? [];。第二个更好,因为我们无法保证每个部署者的配置文件都是完整的——bot.js的loadConfig里正是这么做的(cfg.llm.trigger ??= { at: true, private: true, keyword: false, keywordList: [] })。「靠改数据修好」只修了这一台机器,改代码才是修好了这个程序。 -
stripAt为什么要写@[\w-]{3,15},而不是@\S+(一个 @ 加任意非空白字符)或@[\w-]+(不限长度)?请分别说明这两种替代写法各自会在什么输入上出问题。参考答案
@\S+会把 @ 后面一切非空白字符都吃掉,包括中文、标点、表情符号。用户发「@OWL你好」时它能用(因为中文不是\S?——不,中文是非空白字符,它也会被吃掉),结果「你好」被当成 @ 名字的一部分删掉,正文全丢了。@[\w-]+好一些:它只吃字母数字下划线短横线,中文安全。但它没有长度上限,遇到@OWL_robot_2024_official这种很长的一串会整段吃掉,还可能在某些更复杂的正则里引起大范围的匹配回溯(性能问题)。{3,15}给出了下界和上界:下界保证不会把@a这种短名字误伤(实际上这也是它的一个缺陷:2 个字符的昵称确实删不掉,这是真实取舍),上界保证它不会一次吃太多。真正的要点是:字符类决定「能吃什么」,量词决定「最多吃多少」,两者都要有意识地选,而不是随手写一个.+。」 -
动手题二:给
handleEvent加一条新规则:「如果有人连续两次发一模一样的消息,第二次不回复。」请写出你打算把状态存在哪里、为什么,并说明你会怎么验证它是对的(不需要写出完整代码,写清思路和你需要检查的边界)。参考答案
状态必须存在函数之外(因为
handleEvent每次调用都要能看见上一次的结果),做法和lastReplyAt一样:在模块顶层声明一个const lastMessage = new Map(),键用key(会话标识),值存上一条文本。判断逻辑:取出上一次的文本,如果和这次===相等就直接返回{ reply: null, why: "重复消息" };否则记录这一次的文本再继续。需要检查的边界(这才是这道题的重点):(1)比较的应该是
stripAt之前的文本还是之后?(决定「@她 你好」和「你好」算不算同一条)(2)「连续」的判定要不要带时间窗?如果一个人昨天说「在吗」,今天又说「在吗」,还算重复吗?(如果算,那你实际上是做了一个永久状态,这个 Map 就永远不会瘦下来)(3)不同的人说同一句话算不算重复?(键必须包含user_id,否则群里一个人说「哈哈」,所有人就都不能说「哈哈」了)(4)第 5 条测试(冷却拦下的那条)和你这条新规则的交互:如果一条消息同时命中「重复」和「冷却」,why该报哪个?能答出第 3 条的人,说明他已经在用「状态的作用范围」思考问题了——这正是本章第 10 节要教给你的东西。
自问自答:把知识变成你自己的
下面这些问题没有标准答案,有些甚至没有答案。请不要在页面上浏览,拿一张纸写下来。写的过程就是思考的过程。
- 在你今天读的所有代码里,有哪一行是你「能读出来但写不出来」的?具体是它哪一部分让你写不出——是语法、是思路、还是你根本不知道有这个方法可用?这三种「写不出」需要完全不同的应对方式。
- 「程序 = 数据 + 操作 + 顺序」。请用这三个词描述一次你今天做的非技术的事(做饭、换乘地铁、整理房间),然后问自己:哪一部分最容易出错?是你的操作不会,还是顺序搞反了?
- 你在这一章见到了至少五个「安静地给出错误答案」的坑(隐式类型转换、
Math.round取随机下标、String(undefined)、===两边的类型不一致、正则贪婪)。为什么这类 bug 比「当场报错」的 bug 危险得多?如果让你在项目里选,你更怕哪一种? - 会读代码和会写代码之间,差的到底是什么?请举一个你自己的具体例子(可以不是编程的),说明「看懂」和「能生成」之间的距离。这个距离要靠什么缩短?
segmentsToText里给未知类型返回[${s.type}]。请你想一个场景,说明「有兜底」也可能带来坏处——比如兜底掩盖了一个你本该立刻发现的问题。- 你在第 8 节看到
case "reply": return "";把引用内容丢掉了。如果 OWL 真的按你想的去改进,会发生什么?请把「改进后的收益」和「为它付出的代价」各写一条。 - 这一章里我反复说「先用
const」「别用var」「别用==」。这些是规则还是品味?如果没有它们会怎样?你能不能想出一条「有理由打破它」的情况? - 如果你要向一个完全不懂编程的人解释「为什么
event.sender?.card里那个问号很重要」,你会怎么说?请尽量不用「null」「undefined」这两个词。 - 你已经能读懂
bot.js里的大部分代码了。请列出你现在仍然完全不理解的部分,并按「我猜它大概是什么意思」给出一个猜测。三个月后再来看这份清单——你会发现自己猜对了多少。 - 这一章结束时,你手上有两个东西:一个能跑的
offline-bot.mjs,和一堆读过的代码。如果现在要你向别人证明「我学会了 JavaScript 基础」,你会拿什么出来?是一个能运行的程序、一份能讲出来的笔记、还是能回答别人的提问?这三者的难度排序,和它们的可靠程度排序,是一样的吗?
小结
这一章说了三件事。
一、程序是说明书,不是咒语。它的执行者极其听话又极其死板,所以你必须把「数据、操作、顺序」全部写清楚。写作的本质能力不是数学,而是拆解:把「把 @ 去掉」这种一句话的需求,拆成「字符范围、长度上界、空白处理、多种输入形式」这些可以被机器执行的细节。你这一章学到的每一个语法点(变量、类型、运算符、条件、循环、函数、数组对象、字符串正则、作用域、错误、模块),都是这份说明书里的一个零件。
二、真实的代码里,防御比聪明重要。你看到了 ?. 挡住缺失的键、?? 区分「没配置」和「配置成空」、String() 统一类型、Array.isArray 挡掉非数组、try/catch 保住一条坏数据、loadConfig 在边界上把配置补全。这些写法的共同点是:它们都不是为了「跑通」,而是为了「出错的时候不出大事」。你在第 8 节末尾学到的那四个问题(这个值凭什么一定存在?它来自字面量、文件、网络,还是上一个函数的返回?),是你以后写任何一行属性访问时的检查清单。
三、你自己写出来的那个处理器,是真的。它包含指令匹配、三种关键词模式、@ 判定、基于 Map 的冷却、四层优先级、以及一个能解释自己的 { reply, why } 返回值。它没有网络、没有异步、没有服务器——但它和 bot.js 的骨架是同一种形状。这件事的意义在于:你第一次体会到「我脑子里的规则,变成了屏幕上真实发生的事」。
现在回头看一眼第 1 节里那个电梯的例子。当时你可能会觉得「这不是废话吗,写清楚不就行了」。但你刚才在读 stripAt 的时候,大概体会到了那句「写清楚」有多难:你必须知道 QQ 号最长几位、@ 后面会不会有空格、词字符里包不包括中文——这些都写在真实的、别人定义的规范里,而不在你的脑子里。编程的难,从来不是「想不到」,而是「不知道自己没想到」。
所以这一章真正的收获,也许不是那几十个语法点,而是一种新的习惯:在看到任何一段代码时,先问它「凭什么是这样」,而不是「它大概是这个意思」。你对自己写的每一行也可以这样问。这个习惯一旦长出来,你就已经不是一个「看得懂代码的人」,而是一个「能改代码的人」了。
下一章会给你一件东西,让你从今天开始可以放心地改坏任何代码——Git。而你要做的第一件事,其实是把你刚才写的那个 offline-bot.mjs 保存好:它会是你这一路上第一个可以拿给别人看的作品。
延伸:可以去哪里继续
网站
- MDN · JavaScript 中文文档——最权威、最完整的一手资料。这一章的每一个方法(
padStart、replace、find)在它上面都有一页完整的说明,包含参数、返回值、浏览器兼容性。从今天起,你要查任何方法的行为,都先来这里,别去看二手博客。 - 现代 JavaScript 教程(javascript.info 中文版)——如果你读完这一章觉得「还想再多看一遍、但想要更细」,这是最好的第二本教材。它的讲解方式和你现在需要的完全一致:每个概念都先讲为什么存在,再讲怎么用。建议按它的目录把「JavaScript 基础知识」那一部分读完。
- Node.js 官方学习区——官方出的入门材料,讲的是「Node 与浏览器不同」的那部分:模块系统、文件读写、命令行参数。等你要把今天的处理器变成真程序时,来这里。
- freeCodeCamp · JavaScript 算法与数据结构(中文)——在浏览器里直接写代码、立刻判定对错。它的价值是逼你反复写:这一章你读得多、写得少,而它正好补上写的量。大概需要几十个小时,可以每天做两三题。
- Exercism · JavaScript 轨道——免费的编程练习平台,最大的特色是每道题做完之后有人(或 AI)给你代码评审,告诉你哪里写得不够清楚。当你觉得「我的代码能跑,但不知道写得好不好」时,来这里。
值得读的书(三本,各有各的时间点)
- 《JavaScript 高级程序设计》(第 4 版,Matt Frisbie)——业内通常叫它「红宝书」。它是一本参考书而不是教程:九成的篇幅在讲「这个特性的所有细节」。现在不要通读它,那会让你以为自己不适合学编程。正确的用法是:当你对某个概念(比如闭包、原型、
this)产生了具体困惑时,翻到对应那一章精读。什么时候读:学完本章、能写出一百行能跑的程序之后,作为案头工具书。 - 《你不知道的 JavaScript》(上卷,Kyle Simpson)——它讲的是「你以为你懂、其实没懂」的那部分 JavaScript:作用域、闭包、
this、类型转换、==的完整规则。这一章里我刻意只给了你「够用就好」的版本(比如this我只讲了两条规则),剩下的一半在这里。它薄、写得像聊天、但信息密度很高。什么时候读:当你开始对「为什么会这样」而不是「怎么用」产生好奇的时候。基础要求是:你得先写过几百行代码,否则读它会变成背结论。 - 《代码整洁之道》(Robert C. Martin)——它讲的是命名、函数长度、注释、错误处理这些「不写也能跑」的东西。什么时候读:不是现在。现在读它会让你写不下手——你会因为「这个函数名不够好」而卡半小时。建议的时机是:你写完第一个真实项目(比如把 OWL 改出两三个新功能)之后,回头看自己的代码觉得有点恶心,那时候读它,每一章都会击中你。书里的例子主要是 Java,但原则与语言无关。
提醒不要收藏了就算看过。上面八个资源里,这一章只要求你做一件事:把 offline-bot.mjs 从零重写一遍(不看这一章的代码),写完跑通,然后把它保存到你的项目文件夹里。如果你重写的时候在第 4 步就卡住了,那说明你还没真的学会——回到第 8 节再读一遍,然后再合上。