回到首页

输入关键词,多个关键词用空格分隔。

← 编程语言与英语 · 系列目录

Node.js 命令行程序实例教程

约 6 分钟更新于 2026-11-06
本页目录

这个学期 Coding Bootcamp 最后一个作业是用 Node.js 做一个小程序,正好借此机会学习一下 Node.js。这个教程主要是关于如何用 Node.js 做命令行程序的,涉及服务器和 Web 的部分基本上是没有的。

Node.js 基本介绍

Node.js 是运行在浏览器之外的 JavaScript 运行时,使用 V8 执行 JavaScript,并提供文件系统、网络等接口。它可以写服务器,也可以写命令行程序;不能简单等同于“服务端 JavaScript”或一个语法解析器。

环境安装按 Node.js 官方说明选择受支持的版本。安装之后输入 node 可以进入 REPL,输入 node --version 可以确认版本。本例使用内置模块,不需要安装第三方包;代码保存为 .mjs,明确采用 ES 模块。

任务介绍

既然是实例教程,我们需要有一个例子。这里要做四件事:

  1. 读入一个 UTF-8 文本文件。
  2. 统计行数、词数和词频,找出最常见的十个 trigram,再比较第一名与之后最多十个 trigram 的编辑距离。
  3. 把结果写成 HTML 文件。
  4. 支持从命令行指定输入和输出路径。

原练习使用英文小说,这里仍采用简单的英文分词规则。它不是中文分词器,也不试图完整处理缩写、语言学上的句子边界和所有 Unicode 文本。先说明统计口径,比只给出一个看起来精确的数字重要。

Hello World

万变不离其宗,先写一个 hello.mjs:

console.log('Hello World, this is wdx');

运行 node hello.mjs,看到输出就说明基本环境可以工作了。

下面各节的 JavaScript 代码块依次拼到同一个 parser.mjs 文件中,就是完整程序。不要把 hello.mjs 的示例拼进去。

一:读取文件

文件读取是异步操作,后面的统计必须等读取完成。原稿采用回调,这里改用 await,把读取、统计、输出放在同一条容易检查的执行路径上。

import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

const [input, requestedOutput] = process.argv.slice(2);
if (!input) {
  console.error('用法:node parser.mjs input.txt [output.html]');
  process.exit(1);
}
const parsed = path.parse(input);
const output = requestedOutput ?? path.join(parsed.dir, `${parsed.name}-result.html`);

process.argv 的前两项分别是 Node.js 可执行文件路径和脚本路径,实际传入的参数从第三项开始。用 path.parse 处理扩展名,可以避免文件名含多个点时被截错。

二:数据统计

行数、词数与词频

这里规定:空文件是零行;末尾单个换行符不额外产生一行;支持 LF、CRLF 和 CR 换行。单词只取英文字母及词内部的撇号,再统一小写。这样不会把空格产生的空字符串算成单词。

使用 Map 存储频数,避免单词与普通对象的原型属性同名时出现问题。频数相同的项按字符串排序,使输出顺序稳定。

function rankedCounts(items) {
  const counts = new Map();
  for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
  return [...counts].sort((a, b) =>
    b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
}

function analyze(text) {
  const lines = text === '' ? [] : text.split(/\r\n|\n|\r/);
  if (lines.at(-1) === '') lines.pop();
  const words = text.toLowerCase().match(/[a-z]+(?:'[a-z]+)*/g) ?? [];
  const wordFrequency = rankedCounts(words);
  const trigrams = [];
  for (let i = 0; i + 2 < words.length; i++) {
    trigrams.push(words.slice(i, i + 3).join(' '));
  }
  const top11 = rankedCounts(trigrams).slice(0, 11);
  const comparisons = top11.slice(1).map(([value]) => ({
    left: top11[0][0],
    right: value,
    distance: levenshtein(top11[0][0], value),
  }));
  comparisons.sort((a, b) => a.distance - b.distance);
  return { lineCount: lines.length, wordCount: words.length, wordFrequency,
    topTrigrams: top11.slice(0, 10), comparisons };
}

Trigram 统计

这里的 trigram 是连续三个单词组成的序列。比如 a b c d 会产生 a b c 和 b c d。上面循环的条件是 i + 2 < words.length,所以恰好三个词时也能得到一个结果,不会漏掉最后一组。

和原练习一样,代码把全部单词连成一条序列,因此允许跨行、跨标点组合。如果任务要求不能跨句,就要先切句,再在每句内部生成 trigram。少于三个词时结果为空;不足十一种 trigram 时只比较实际存在的项。

编辑距离

Levenshtein 距离是通过插入、删除、替换,把一个序列变成另一个序列所需的最少操作数。使用动态规划即可求解:当前位置的答案来自删除、插入和替换三个候选方案。

function levenshtein(a, b) {
  const left = Array.from(a);
  const right = Array.from(b);
  let previous = Array.from({ length: right.length + 1 }, (_, j) => j);
  for (let i = 1; i <= left.length; i++) {
    const current = [i];
    for (let j = 1; j <= right.length; j++) {
      current[j] = Math.min(
        previous[j] + 1,
        current[j - 1] + 1,
        previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1),
      );
    }
    previous = current;
  }
  return previous[right.length];
}

这里只保留上一行和当前行,空间与第二个字符串的长度成正比,时间复杂度仍为两个序列长度的乘积。空字符串也能正常处理。

这里比较的是包含空格的整个 trigram 字符串,按 Unicode 码点计算距离,不是单词级距离,也不等同于用户看到的字素数量。编辑距离反映表面差异,不直接表示语义相似度。

三:输出 HTML

思路和原来一样:先把统计结果存下来,再一次生成 HTML。文件名和文本数据写入 HTML 时必须转义,不能让输入中的 <、& 等字符变成页面标记。

function escapeHtml(value) {
  const entities = { '&': '&amp;', '<': '&lt;', '>': '&gt;',
    '"': '&quot;', "'": '&#39;' };
  return String(value).replace(/[&<>"']/g, char => entities[char]);
}

function renderReport(filename, stats) {
  const list = rows => rows.map(([value, count]) =>
    `<li>${escapeHtml(value)}: ${count}</li>`).join('\n');
  const comparisons = stats.comparisons.map(item =>
    `<li>${escapeHtml(item.left)} / ${escapeHtml(item.right)}: ${item.distance}</li>`
  ).join('\n');
  return `<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>${escapeHtml(filename)} 统计结果</title></head>
<body>
<h1>${escapeHtml(filename)} 统计结果</h1>
<p>行数:${stats.lineCount};词数:${stats.wordCount}</p>
<h2>Trigram 频数(最多十项)</h2><ol>${list(stats.topTrigrams)}</ol>
<h2>与最高频 Trigram 的编辑距离</h2><ol>${comparisons}</ol>
<h2>词频</h2><ol>${list(stats.wordFrequency)}</ol>
</body></html>`;
}

try {
  const text = await readFile(input, 'utf8');
  const stats = analyze(text);
  await writeFile(output, renderReport(input, stats), { encoding: 'utf8', flag: 'wx' });
  console.log(`行数:${stats.lineCount};词数:${stats.wordCount}`);
  console.log(`报告已生成:${output}`);
} catch (error) {
  console.error(`处理失败:${error.message}`);
  process.exitCode = 1;
}

writeFile 的 wx 标志要求新建文件,如果输出已存在就报错,避免重新运行时覆盖旧报告。输入和输出指向同一个已有文件时也会失败。文件系统 API 的具体语义见 Node.js 官方文档。

四:传入参数与检查结果

创建一个 sample.txt,内容如下:

one two three
one two three

运行:

node parser.mjs sample.txt
node parser.mjs sample.txt another-report.html

按上面的口径,应得到两行、六个词,one two three 出现两次。三个词恰好组成一组时不应漏算;空文件应生成零行、零词的报告;输入不存在时应返回非零退出状态。这些小样本比只用一本小说观察“有没有输出”更容易发现边界错误。

文件路径中的空格需要在命令行加引号。此程序会把整个文件及统计结果读入内存,适合学习和适量文本;大文件应考虑流式读取,并在分块之间保留未完成的单词与最后两个词。

总结

至此,读取、计算、输出和参数处理四件事就串起来了。这个教程不涉及网络,却能练习 JavaScript 的数组、字典、函数以及 Node.js 的文件操作。

2015 年的原始作业仓库保留为历史参考,本文代码是迁移时校订后的版本,两者并不完全相同。有关客户端、网络与服务器的整体关系,可以接着阅读 Web 快速入门。

相关文章