Node.js 读 Excel 我踩了 3 个坑,最后一个查遍 Stack Overflow 没答案

0 阅读4分钟

作者:月夏 | 8 年前端 / 转型 Node.js 全栈 Day 1
关键词:xlsx、SheetJS、TypeScript、Commander、Node.js、踩坑

最近在练手 Node.js,想做一个能批量读 Excel 的 CLI 工具——手头没有合适数据,就用 mock 造了一份 12 个 sheet 的 demo(产品 A 到 L,都是化名,纯练手用),跑起来发现事情没那么简单。

用 SheetJS 三行代码——结果踩了 3 个坑,最后一个查遍 Stack Overflow 没答案,只能读源码解决。


最小代码(你以为这就完事了)

import * as XLSX from 'xlsx';

const wb = XLSX.readFile('./examples/demo.xlsx');
console.log(wb.SheetNames);  // ['产品 A', '产品 B', '产品 C', ...]

跑一下:

Error: Cannot access file ./examples/demo.xlsx

诶?文件明明在那里啊?


坑 1:xlsx 0.20 ESM 版读不到本地文件 ⭐⭐⭐

完整报错

Error: Cannot access file ./examples/demo.xlsx
    at fileFree (xlsx.mjs:1:1)

官方推荐这么写

// 官方文档:把 Node 的 fs 挂到 globalThis
(globalThis as any)._fs = await import('node:fs');

我照做了——结果还是抛一样的错。

读源码才明白

把 xlsx 的 xlsx.mjs 翻出来看,找到关键逻辑:

function get_fs() {
  if (typeof _fs !== 'undefined') return _fs;     // 找 globalThis._fs
  throw new Error('Cannot access file...');        // 找不到就抛
}

真相:xlsx 0.20 的 ESM/浏览器版故意不带 fs(避免被打包进前端构建),要求使用者自己注入。但 tsx 这种多模块上下文下注入会失效(模块作用域隔离),官方文档没说。

解决方案:自己读 Buffer 再喂给 xlsx

绕开 XLSX.readFile(),先用 Node 的 fs.readFileSync 读成 Buffer,再交给 XLSX.read() 解析:

import { readFileSync } from 'node:fs';

const buf = readFileSync('./examples/demo.xlsx');   // 手动开门
const wb  = XLSX.read(buf, { type: 'buffer' });     // 把屋里的东西直接给 SheetJS

跑一下:✅ 通过。后面所有代码都基于这个写法。


坑 2:type: 'buffer' 不是"文件类型" ⭐⭐

第一次写时我没明白第二个参数含义,文档说"input data type"——我以为是 Excel 文件的类型。

实测:改成 binary 直接抛错:

XLSX.read(buf, { type: 'binary' });
// TypeError: x.charCodeAt is not a function

查源码才明白:type 是告诉 SheetJS 你传的这个 JS 变量是什么数据类型,不是文件本身:

type 值期望的 JS 变量我的 Buffer 能不能用
'buffer'Buffer 对象✅ 能用
'binary'binary string(每字符=一字节)❌ Buffer 没有 charCodeAt
'array'普通数组❌ 维度对不上
'base64'base64 字符串❌ 需要先编码

这坑的通用教训:用错类型参数时 SheetJS 不会友好提示"类型不匹配",它只会尝试在 Buffer 上调用字符串方法——TypeScript 类型系统存在的意义。


坑 3:tsx watch 默认启动 Inspector,污染控制台 ⭐

跑 npm run dev 准备热重载:

> ljy-cli-excel-tool@0.1.0 dev
> tsx watch src/index.ts

Debugger listening on ws://127.0.0.1:54165/9f912ea2-...
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.

什么鬼?我又没开调试器。

排查

  • 查 NODE_OPTIONS 环境变量:没设
  • 查 .vscode/launch.json:项目内没有
  • 查 tsx 版本:4.23.15

真相:tsx 4.x 的 watch 模式内置启用了 Node Inspector 端口(HMR 需要),没法通过 --inspect=false 关掉。

解决:用 nodemon + tsx 解耦

把 watch 和 TS 跑解耦:

{
  "scripts": {
    "dev": "nodemon --watch src --ext ts,json --exec \"tsx src/index.ts\""
  }
}
  • nodemon 接管 watch(不启 Inspector)
  • tsx 负责跑 TypeScript(不带 watch 模式)
  • 两个解耦,跨 Node 18 / 20 / 22 都稳

跑一下:

[nodemon] 3.1.14
[nodemon] watching path(s): src\**\*
[nodemon] starting `tsx src/index.ts`

共 12 个 sheet,耗时 226 ms
[1] 产品 A
    rows=57  cols=29
...

完整代码

// src/index.ts
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import * as XLSX from 'xlsx';
import chalk from 'chalk';

interface SheetSummary {
  name: string;
  rows: number;
  cols: number;
  header: string[];
}

const program = new Command();
program
  .name('ljy-cli-excel')
  .argument('<file>', '输入 Excel 文件路径')
  .option('-v, --verbose', '显示详细表头')
  .option('--json', '以 JSON 格式输出')
  .action((file: string, opts: { verbose?: boolean; json?: boolean }) => {
    const buf = readFileSync(file);
    const wb  = XLSX.read(buf, { type: 'buffer' });

    const summaries: SheetSummary[] = wb.SheetNames.map(name => {
      const ws  = wb.Sheets[name];
      const ref = ws['!ref'] ? XLSX.utils.decode_range(ws['!ref']) : null;
      const header = ref
        ? Array.from(
            { length: ref.e.c - ref.s.c + 1 },
            (_, i) => ws[XLSX.utils.encode_cell({ r: ref.s.r, c: ref.s.c + i })]?.v
          )
        : [];
      return {
        name,
        rows: ref ? ref.e.r - ref.s.r : 0,
        cols: ref ? ref.e.c - ref.s.c + 1 : 0,
        header: header.slice(0, 6).map(String),
      };
    });

    if (opts.json) {
      console.log(JSON.stringify(summaries, null, 2));
      return;
    }

    console.log(chalk.bold(`共 ${summaries.length} 个 sheet\n`));
    summaries.forEach((s, i) => {
      console.log(chalk.cyan(`[${i + 1}] ${s.name}`));
      console.log(`    rows=${s.rows}  cols=${s.cols}`);
      if (opts.verbose) console.log(`    header: ${s.header.join(' | ')}`);
    });
  });

program.parse();

实战输出

$ npx tsx src/index.ts examples/demo.xlsx -v

image.png

写在最后

3 个坑花了我整个下午,但这套脚手架我整理成了可复用的 skill,后续 12 周里我会按这个套路搭 8 个 Node 项目,目标是从 8 年前端转型到能独立交付的 Node 全栈。

如果你也在做 Node + Excel 工具,希望这篇文章能节省你的时间。


关于我:月夏 / 8 年 toB 前端 / 正在转 Node.js + Java 全栈 / 转型日记持续更新中,欢迎关注。