ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Node.js+WebSocket实现网页调用本机cmd命令行控制台

Node.js+WebSocket实现网页调用本机cmd命令行控制台 浏览器里点一个按钮本机就跑一条命令输出结果实时显示在网页上——这个需求听起来简单真动手会发现处处是坎。前阵子我给自己那台常年挂着跑备份和清理任务的机器做了这么个东西目标是替代每次都要开终端敲一遍的低效操作打开 http://127.0.0.1:8765 就能看到几个任务按钮点一下执行 cmd 命令行日志直接滚在页面上。关键词就三个html、cmd、命令行但把这三样串起来的过程比我预想的曲折。网上搜到的教程一大半是让你去改 IE 的 ActiveX 安全设置剩下的要么是几百年前的老黄历要么只讲半截。这里把从选型到跑通的完整过程记一遍代码全部贴出来前端页面、后端服务、编码处理、安全兜底都有照着抄能直接用。适合两类人看一是想给自己做本机自动化面板的运维或开发二是想把命令行工具的交互做得友好一点的普通用户。前置要求只有一条——机器上装了 Node.js 18 以上版本其他依赖都是 npm 装的不需要额外配置什么环境。1. 先说结论网页直接调 cmd 这条路走不通我最初的想法很天真HTML 里写一段 JS调个什么 API 把命令发给系统不就完事了。翻了半天文档才彻底明白这条路在现代浏览器里是被彻底封死的不是没找对方法是设计上就不允许。1.1 浏览器为什么把这条路堵死现代浏览器运行网页代码时跑在一个叫沙箱的环境里。这个沙箱的规则很硬网页能碰的东西只有 DOM、Cookie、LocalStorage、网络请求这些文件系统和进程管理完全在它的权限之外。你写require(child_process)或者exec(dir)浏览器控制台会直接给你一个 is not defined连语法层面都不认。为什么不给道理很简单。网页是你从别人服务器上下载下来的代码你打开一个页面等于把一段陌生代码请进了自己电脑。如果这段代码能调系统命令那打开任何一个恶意页面你的电脑就沦陷了。浏览器厂商为了堵这个洞付出了巨大的兼容性代价。所以十几年前那些靠 IE ActiveX 控件调用WScript.Shell的老方案随着 IE 退役一起进了历史垃圾堆——现在还教你改 IE 安全设置开 ActiveX 的文章直接关掉别看那不只是过时是把机器往火坑里推。但这不意味着需求没法实现。关键在于换一个思路网页不直接跟操作系统对话而是跟一个本机服务对话由这个服务去执行命令。网页和本机服务之间走 HTTP 或者 WebSocket这是浏览器完全允许的常规操作本机服务不是浏览器它是你自己写的程序想调什么调什么。桥梁架在中间两边的限制就都绕开了。1.2 三种绕行方案的横向对比我把当时能想到的路子都列出来比了一遍结论写在表格里方案原理能不能用主要问题IE ActiveX网页调WScript.Shell基本废弃只有老 IE 支持安全性极差系统默认禁用HTA 文件本地.hta被当作可信程序勉强可用本质是本地程序不是网页不能远程访问界面老旧本地服务桥网页通过 HTTP/WS 找本机进程推荐需要自己写一个几十行的后端Electron 打包把网页塞进桌面应用壳子可用体积大装个东西几百兆杀鸡用牛刀HTA 那条路我试了一下确实能跑写个.hta文件双击就执行但它有个致命问题它是一份本地文件不是服务。你换个设备访问不了也没法做成打开浏览器就能用的体验而且 HTA 本身也被微软标记为不推荐技术了。Electron 那条路更重我做过几个 Electron 项目打出来的包动辄 200MB 起就为了跑几条命令实在不值。最后选定的是本地服务桥方案后端用 Node.js 起一个只监听127.0.0.1的 HTTP 服务顺便挂一个 WebSocket 通道前端就是一个普通 HTML 页面什么都不特殊。整个项目两个依赖包代码加起来不到 300 行双击一个 bat 就能启动。1.3 这个方案的边界必须提前说清楚选了本地服务桥有一条线必须划死这个服务只能监听 127.0.0.1也就是本机回环地址绝对不能监听 0.0.0.0 或者你的局域网 IP。原因不用绕着说——一个能执行任意系统命令的服务一旦暴露在网络上等于把机器钥匙挂门口了。我只在127.0.0.1上起服务加上一个简单的 token 校验再加一层命令白名单三层保险叠起来。这三层设计其实是递进的监听回环解决了外面的人根本连不上token 解决了本机其他程序或者误开的页面连不上白名单解决了就算连上了也只能跑我允许的那几条命令。后面第 2、3 节会把这三层怎么落地讲透。我到现在都把这个服务当成本机工具用需要远程就在机器上手动操作绝不为了图方便开个端口出去。2. 整体架构设计网页与命令行之间需要一座桥想清楚做什么之后剩下的就是拆结构。结构拆得对代码写起来就是填空拆得不对写到一半就得推倒重来。这一节讲清楚三层怎么分、为什么用 Node 而不是 Python、以及安全边界具体落在哪个位置。2.1 三层结构怎么划分整个东西拆成三层各管一段互不越界展示层前端 HTML只负责画界面、接输入、显示输出。它唯一会做的事是跟本机服务建立 WebSocket 连接把用户点的任务名发过去把回来的数据打到屏幕上。它不知道命令怎么执行的也不该知道。调度层Node 服务负责收前端请求、校验身份、查白名单、调用系统命令、把输出流实时推回去。所有判断逻辑都在这儿。执行层操作系统child_process起一个子进程真正的 cmd 或者 PowerShell 在这里跑。这么分的核心考虑是职责单一。前端不碰危险逻辑即使页面被改坏也出不了大事后端集中处理所有校验改规则只用改一个地方执行层永远是被调用者不主动做决定。三层之间用 WebSocket 通信而不是 HTTP 轮询是因为命令的输出是流式的——像ping这种命令会一秒一行地吐用 HTTP 轮询要么延迟高要么浪费资源WebSocket 天生适合这种持续推送的场景。2.2 为什么是 Node.js 而不是 PythonPython 也能干这事Flask 起个服务配个subprocess十行就能跑通。但我最后选了 Node有三个实打实的理由。第一个理由是语言统一。前端是 JS后端也是 JSWebSocket 的消息格式、编码处理、字符串操作两边的习惯完全一致不用在脑子里切语言。写业务逻辑的时候这个优势特别明显一个输出分片拼接的逻辑前后端写法几乎一样。第二个理由是npm 生态里 ws 和 iconv-lite 这两个库太成熟。ws是 Node 上 WebSocket 服务端的标准实现文档全、坑少iconv-lite处理 GBK 到 UTF-8 的转换非常稳。Python 那边对应的库也有但版本兼容问题在 Windows 上偶尔会咬人我在别的项目里踩过。第三个理由是启动速度。Node 起一个 HTTP 服务基本是瞬时的双击 bat 到页面能打开一秒钟之内。Python 解释器冷启动在某些 Windows 环境上要等一下。这个东西的定位是随手开随手用的小工具启动速度直接影响用不用得下去。注意如果你的机器上已经有 Python 环境并且熟悉 Flask用 Python 也完全可以架构是通的工具选择不影响方案本身。2.3 安全边界的三道闸门前面提到三层保险具体落在代码里是这样第一道监听地址写死回环。server.listen(PORT, 127.0.0.1)第二个参数绝对不能省。很多人图方便写成server.listen(PORT)在部分配置下会监听所有网卡这是最危险的习惯。第二道token 校验。前端连接 WebSocket 时带上一个 token后端比对。这个 token 存在服务端的环境变量或者配置文件里启动时打印到控制台用户手动填到页面上。有人会说本机用不着这个——但本机不等于安全浏览器里任何一个网页都可能尝试连localhost的端口虽然跨域限制让大部分尝试失败但加一道 token 几乎零成本为什么不加。第三道命令白名单。这是最硬的一道。后端不接收任意命令字符串只接收任务名任务名到命令的映射写死在服务端代码里。想加新命令得改代码、重启服务而不是在网页上敲。这样即使前两道失守攻击者能跑的最多也就是我预先定义好的那几条无害命令比如ipconfig、ping、systeminfo。三道闸门叠加之后这个东西的暴露面就被压得很小了。后面第 5 节还会讲两个看起来没事其实有坑的细节比如命令参数的拼接方式那个地方一不小心就会把白名单绕过去。3. 从零搭建后端服务实现结构想清楚之后写代码就是按部就班。这一节把后端完整实现贴出来从环境准备到命令执行每一段都解释为什么这么写。3.1 环境准备与项目结构先确认 Node 版本node -v输出要在 v18 以上。低版本也不是不能跑但ws的新版本和一些语法特性会有差别为了少踩坑建议升一下。项目目录结构保持最简local-cmd-console/ ├── public/ │ └── index.html # 前端页面 ├── server.js # 后端服务 ├── tasks.json # 任务白名单配置 └── start.bat # 一键启动依赖只有两个package.json内容如下{ name: local-cmd-console, version: 1.0.0, private: true, main: server.js, scripts: { start: node server.js }, dependencies: { ws: ^8.17.0, iconv-lite: ^0.6.3 } }private: true这行建议加上防止哪天手滑npm publish把本地工具传上去。依赖装好之后node_modules大概十几兆很轻。之所以把任务配置单独抽成tasks.json是为了改命令不用动server.js的代码维护上清爽一点。顺带说一句目录里为什么没看到package-lock.json因为命令行工具项目我习惯把它留着一起提交这样换机器npm ci装出来的依赖版本完全一致不会出现我这儿好好的你那儿报错的经典问题。这个习惯是从一次线上事故里学来的——当时一个库的小版本升级改了参数默认值本地能跑服务器上挂查了三个小时。3.2 静态文件服务与 WebSocket 接入server.js的开头部分先搭 HTTP 服务和静态文件托管const http require(http); const fs require(fs); const path require(path); const { spawn } require(child_process); const { WebSocketServer } require(ws); const iconv require(iconv-lite); const HOST 127.0.0.1; const PORT 8765; const TOKEN process.env.CONSOLE_TOKEN || change-me-local-token; const MIME { .html: text/html; charsetutf-8, .js: text/javascript; charsetutf-8, .css: text/css; charsetutf-8, }; const server http.createServer((req, res) { let urlPath req.url.split(?)[0]; if (urlPath /) urlPath /index.html; // 路径穿越防护只允许访问 public 目录下的文件 const safePath path.normalize(path.join(__dirname, public, urlPath)); const publicRoot path.join(__dirname, public); if (!safePath.startsWith(publicRoot)) { res.writeHead(403); res.end(forbidden); return; } fs.readFile(safePath, (err, data) { if (err) { res.writeHead(404); res.end(not found); return; } res.writeHead(200, { Content-Type: MIME[path.extname(safePath)] || application/octet-stream }); res.end(data); }); });这里有个细节值得单独拎出来说路径穿越防护。如果不做校验请求http://127.0.0.1:8765/../../secret.txt这种路径服务器可能就把public目录以外的文件读出来了。我用path.normalize把路径规范化之后检查它是不是还在public目录下面不是就返回 403。虽然这个服务只在本机跑但这个习惯不能省写完这段之后我拿../各种变体试了一遍确认都被挡住了才继续往下写。HTTP 服务搭好之后把 WebSocket 挂上去const wss new WebSocketServer({ server, path: /ws }); wss.on(connection, (ws, req) { const url new URL(req.url, http://${req.headers.host}); const token url.searchParams.get(t); if (token ! TOKEN) { ws.send(JSON.stringify({ type: fatal, data: 鉴权失败 })); ws.close(1008, unauthorized); return; } ws.send(JSON.stringify({ type: ready, data: 连接已建立 })); ws.on(message, (raw) { handleMessage(ws, raw.toString()); }); });path: /ws这个选项让 WebSocket 只在/ws这个路径上响应其他路径的连接会被拒掉。token 通过 URL 查询参数传虽然理论上会出现在访问日志里但本地服务没有日志采集这个顾虑不存在。真实项目里我会用Sec-WebSocket-Protocol头传 token这里为了前端代码简单就用了查询参数。3.3 用 spawn 执行命令并实时回传输出核心的执行逻辑先加载任务白名单const TASKS JSON.parse(fs.readFileSync(path.join(__dirname, tasks.json), utf8)); function handleMessage(ws, raw) { let msg; try { msg JSON.parse(raw); } catch (e) { ws.send(JSON.stringify({ type: err, data: 消息格式错误 })); return; } if (msg.type ! run) return; const task TASKS[msg.taskId]; if (!task) { ws.send(JSON.stringify({ type: err, data: 未授权的任务 })); return; } runTask(ws, task); }注意TASKS[msg.taskId]这一句——前端传过来的是任务 ID后端去白名单里查。如果查不到就直接拒绝绝不拼接用户输入的命令字符串。这是最关键的一道防线后面 5.3 会展开讲为什么。执行部分用spawn不用execfunction runTask(ws, task) { const startTime Date.now(); ws.send(JSON.stringify({ type: start, data: task.title })); const child spawn(task.bin, task.args || [], { shell: task.shell true, windowsHide: true, cwd: task.cwd || __dirname, }); const push (stream, data) { ws.send(JSON.stringify({ type: stream, data: iconv.decode(data, gbk), })); }; child.stdout.on(data, (buf) push(out, buf)); child.stderr.on(data, (buf) push(err, buf)); child.on(close, (code) { const cost Date.now() - startTime; ws.send(JSON.stringify({ type: exit, code, cost })); }); child.on(error, (e) { ws.send(JSON.stringify({ type: err, data: 启动失败: e.message })); }); }为什么用spawn不用exec这里必须讲明白。exec会把命令的完整输出先缓存在内存里等命令结束一次性给你还默认限制了一个缓冲区大小输出一多就报maxBuffer exceeded。spawn则是流式的命令吐一行它就能拿到一行通过stdout.on(data)实时推出去。像ping、ffmpeg这类会持续输出的命令用exec你只能干等到结束用spawn才能看到实时滚动体验天差地别。windowsHide: true这个选项也是必须的——不加的话每次执行命令 Windows 都会弹出一个黑色控制台窗口闪一下虽然不影响功能但很膈应。3.4 中文乱码处理iconv.decode(data, gbk)这行是专门解决中文乱码的值得单独一节讲。Windows 中文版的 cmd 默认使用GBK代码页 936编码输出而 Node.js 从缓冲区读到的数据是原始字节buf.toString()默认按 UTF-8 解释中文就全变成了问号或者怪符号。我用同一台机器做了对照测试三种处理方式的结果差别很明显处理方式代码结果直接 toStringbuf.toString()中文全乱码切换代码页命令前加chcp 65001中文正常但部分老程序仍有问题iconv 转码iconv.decode(buf, gbk)中文稳定正常chcp 65001那个方案我也用过一阵它把控制台代码页切成 UTF-8大部分现代程序都没问题但个别老工具尤其是一些国产系统工具在 UTF-8 代码页下会吐乱码反而更糟。最后统一用iconv-lite在 Node 侧做转码不管命令自己输出什么代码页我都在拿到缓冲区之后按 GBK 解这个方案在我测试的十几条命令上全部正常。tasks.json的配置大概长这样{ ip: { title: 查看本机网络配置, bin: ipconfig, args: [/all], shell: false }, disk: { title: 查看磁盘占用, bin: wmic, args: [logicaldisk, get, name,size,freespace], shell: false }, pinggw: { title: 测试网关连通性, bin: ping, args: [-n, 4, 192.168.1.1], shell: false } }这里bin和args分开写是有讲究的。分开写的时候shell: false命令以数组形式传递给系统参数不会被 shell 二次解释天然就避免了命令注入。如果把整条命令拼成一个字符串再交给 shell 执行那args里出现、|、这些符号就会被当成管道和重定向执行这是最经典的一类漏洞。我所有的任务配置都用数组形式绝不拼字符串。4. 前端页面把控制台做得像回事后端跑通了前端其实简单。但有两点决定了这个东西好不好用一是输出区域的滚动和渲染要流畅二是任务触发要直观。这一节把页面代码完整走一遍。4.1 页面骨架与终端样式前端就是一个静态 HTML 文件放在public/index.html。骨架部分!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title本机命令控制台/title style * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: Consolas, Microsoft YaHei, monospace; background: #1e1e1e; color: #d4d4d4; height: 100vh; display: flex; flex-direction: column; } header { padding: 12px 16px; background: #252526; border-bottom: 1px solid #333; display: flex; gap: 8px; flex-wrap: wrap; } button { background: #0e639c; color: #fff; border: none; padding: 8px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; } button:hover { background: #1177bb; } button:disabled { background: #444; cursor: not-allowed; } #output { flex: 1; overflow-y: auto; padding: 16px; white-space: pre-wrap; word-break: break-all; font-size: 13px; line-height: 1.6; } .line-err { color: #f48771; } .line-sys { color: #6a9955; } .line-exit { color: #dcdcaa; } /style /head body header idbar/header div idoutput等待连接.../div script srcapp.js/script /body /html样式上我刻意模仿终端配色深色背景加等宽字体white-space: pre-wrap保证命令输出的换行和空格原样保留。这里有个小坑如果不用pre-wrap而是默认的normal输出里的连续空格会被折叠成一个格式化对齐的内容全乱了。word-break: break-all是为了处理超长行比如某些命令吐出一整行没有空格的路径不出横向滚动条。4.2 WebSocket 连接与鉴权流程前端逻辑单独放app.js核心是连接建立和消息分发const token new URLSearchParams(location.search).get(t) || ; const ws new WebSocket(ws://127.0.0.1:8765/ws?t${encodeURIComponent(token)}); const output document.getElementById(output); const bar document.getElementById(bar); let connected false; ws.onopen () { connected true; }; ws.onmessage (ev) { const msg JSON.parse(ev.data); switch (msg.type) { case ready: append(连接已建立可以执行命令, line-sys); break; case out: append(msg.data); break; case err: append(msg.data, line-err); break; case exit: append( 执行结束退出码 ${msg.code}耗时 ${msg.cost}ms, line-exit); break; case fatal: append(鉴权失败请检查 URL 中的 token, line-err); break; } }; function append(text, cls) { const div document.createElement(div); if (cls) div.className cls; div.textContent text; output.appendChild(div); output.scrollTop output.scrollHeight; }append里用textContent而不是innerHTML这个选择是故意的。命令输出里难免出现、、这些字符如果用innerHTML拼进去轻则显示错乱重则被注入脚本。textContent是纯文本赋值浏览器不做解析安全且正确。4.3 任务按钮与自定义输入任务按钮是动态生成的前端只需要知道有哪些任务 ID 和标题具体命令是什么前端完全不知道const TASK_LIST [ { id: ip, title: 网络配置 }, { id: disk, title: 磁盘占用 }, { id: pinggw, title: 网关连通性 }, ]; TASK_LIST.forEach((t) { const btn document.createElement(button); btn.textContent t.title; btn.onclick () { if (!connected) return; append(\n$ ${t.title}, line-sys); ws.send(JSON.stringify({ type: run, taskId: t.id })); }; bar.appendChild(btn); });这个设计有个好处前端和后端的耦合只剩一个任务 ID。以后想加新任务改tasks.json加一条前端这里加一行标题就完事了。命令本体在后端前端连看都看不到。我甚至试过把前端页面整个换掉只保留 WebSocket 连接逻辑后端一行不改功能照旧。如果想支持自定义命令输入框也不是不行但那样就把安全性寄托在前端的自觉上了。我的做法是自定义输入默认关闭需要的时候在服务端开一个白名单前缀校验比如只允许以ping、ipconfig、systeminfo开头的命令而且参数里出现、|、;、一律拒绝。即使这样我还是更推荐老老实实配任务按钮用起来也更快。5. 踩坑记录与关键细节真正让我花了时间的不是写代码是这几个坑。每个坑都让我来回折腾了至少半小时记下来省得后来人重走。5.1 Windows 内部命令必须走 cmd /c这个坑第一次撞上特别容易懵。我配了一条任务想执行dir结果spawn直接报错ENOENT。查了半天才明白dir、echo、set、copy、type这些不是独立的可执行文件它们是 cmd.exe 的内置命令spawn找的是.exe系统里没有dir.exe这个东西自然找不到。解法有两种。一种是显式调用 cmd{ dirtask: { title: 列出当前目录, bin: cmd.exe, args: [/c, dir], shell: false } }/c参数的意思是执行完这条命令就退出不写的话 cmd 会一直挂着等你输入进程不退出前端就永远等不到close事件。另一种是把shell: true打开让 Node 自己帮你套一层 shell。但我不推荐这么做因为shell: true意味着命令字符串会被 shell 解释注入风险就回来了。判断标准很简单外部命令.exe用第一种数组形式内置命令统一写cmd.exe /c xxx全程shell: false。这样参数永远不被二次解释。5.2 长任务与超时控制ping只跑四次要几秒钟没问题。但我后来加了一个ffmpeg转码任务一跑就是好几分钟中间如果前端刷新了后端那个子进程就成了孤儿进程一直占着 CPU 不放。这个坑我是过了几天发现机器 CPU 偏高才注意到的。修法是在 WebSocket 关闭的时候主动杀掉子进程let running null; wss.on(connection, (ws, req) { // ... 鉴权逻辑 ws.on(close, () { if (running) { running.kill(); running null; } }); ws.on(message, (raw) { const msg JSON.parse(raw.toString()); if (msg.type ! run) return; const task TASKS[msg.taskId]; if (!task) return; running runTask(ws, task); }); });同时给runTask加一个可选的超时参数超过时间还没结束就主动杀function runTask(ws, task) { const child spawn(task.bin, task.args || [], { shell: false, windowsHide: true, }); const timeout task.timeout || 300000; const timer setTimeout(() { child.kill(SIGTERM); ws.send(JSON.stringify({ type: err, data: 任务超时已终止 })); }, timeout); child.on(close, () clearTimeout(timer)); return child; }Windows 上child.kill()对某些程序的终止不一定彻底如果遇到杀不掉的可以用taskkill /pid xxx /t /f强制结束整棵进程树。这个我遇到过一次是某个视频处理工具的子进程最后靠taskkill解决的。5.3 参数校验的一个隐藏坑回到白名单这一层。我一开始以为只接收任务 ID 去查表就安全了后来想了想发现还有个地方要注意如果任务配置里的args里包含了用户可控的内容那就等于还是把用户输入带进了命令参数。举个我自己差点犯的错我加了一个ping 指定主机的任务想在前端加个输入框让用户填 IP。如果直接把用户填的字符串放进args数组看起来数组形式很安全实际上用户可以填127.0.0.1 shutdown /s /t 0这种内容——虽然shell: false时不会被解释但如果哪天有人把shell改成true这个洞就开了。所以我的处理原则是只要参数里有用户输入就必须做正则校验比如 IP 必须匹配^[0-9a-fA-F\.:]$域名必须匹配^[a-zA-Z0-9\.\-]$。校验不通过直接拒绝不尝试过滤危险字符——过滤的思路太容易被绕过了白名单校验才是正解。提示这个原则在本地工具里也值得坚持。因为本地工具经常被自己随手改一旦某个版本放开了校验后面很容易忘记加回来。5.4 打包成一键启动最后是让它用起来顺手。写一个start.bat放在项目根目录echo off cd /d %~dp0 node server.js pausecd /d %~dp0这行是保证脚本切换到 bat 文件所在的目录这样双击运行的时候路径永远正确。末尾的pause是为了万一启动报错窗口不会一闪而过能看清错误信息。想更隐藏一点可以用start /min最小化启动或者用 VBS 包一层让黑窗口完全不显示。但我建议先别隐藏因为出问题的时候看不到日志会很难受。等到稳定运行一段时间确认没问题了再考虑隐藏。还有个更省事的做法在package.json里配好scripts.start然后用npm start启动跟start.bat效果一样。我两个都留着看当时心情用哪个。6. 常见问题速查与排查思路用了一段时间把遇到过的问题整理成表出现类似症状可以直接对号入座。现象大概率原因排查方向页面打开一片空白静态服务没起来或端口被占看控制台有没有报EADDRINUSE换端口WebSocket 一直连不上token 没带或带错检查 URL 里的?t参数和后端打印的对比命令执行了但看不到输出编码问题或没监听 stderr确认iconv.decode用了检查 stderr 监听中文变成问号缓冲区按 UTF-8 解释了改成iconv.decode(buf, gbk)或试cp936报ENOENT命令不存在或是 cmd 内置命令换成cmd.exe /c 命令的写法执行完页面没反应没收到close事件检查是不是命令挂住了加cmd /c让它退出长命令跑一半卡住输出缓冲或前端渲染慢用spawn流式输出避免exec黑窗口一闪一闪没有隐藏子进程窗口加windowsHide: true命令一直不结束交互式命令等输入这类命令不要放进来或者用管道喂输入刷新页面后 CPU 占用高子进程没被回收在ws.on(close)里 kill 掉除了表格里的再补两个排查技巧。第一个是分而治之症状是页面没输出可能是后端没执行、可能是执行了没推送、可能是推送了前端没渲染。加三行console.log分别在runTask入口、push函数里、ws.onmessage里打点一眼就能看出断在哪一环。第二个是拿命令行直接验证任何怀疑的命令先在真正的 cmd 窗口里跑一遍确认它本身输出正常再怀疑代码。我有一半的时间浪费在以为是代码问题结果是命令本身参数写错了上这个习惯能省不少事。再分享两个实际使用下来的心得。一是不要贪多白名单里放五到十个高频任务足够了任务太多按钮排不下反而找不到想点的那个。二是给每个任务加一个预估耗时的提示像清磁盘这种要跑十几秒的任务用户点了之后会以为卡住了加一行小字提示预计 10 秒体验会好很多。三是输出的行数要限制比如只保留最近 2000 行超出就丢弃最老的。不然跑一个输出几万行的命令页面 DOM 节点堆到几万个浏览器直接卡死。这个坑我是在跑一个日志分析命令的时候踩到的页面卡了足足半分钟才恢复。后续如果想让这个控制台更实用可以做两件事。第一是加个历史记录把每次执行的任务名、时间、退出码存到本地文件出问题的时候能回溯。第二是把任务配置做成可热加载的改完tasks.json不用重启服务加个小文件监听就行。这两个我后来都加上了用起来确实方便不少尤其是热加载那个改了配置刷新页面就能生效不用来来回回重启。这个项目从想法到跑通用了我两个晚上中间走弯路主要是在编码和内置命令那两个坑上。如果一开始就有人把这些点列清楚估计一个晚上就够了。代码量不大但麻雀虽小五脏俱全进程管理、流式通信、编码转换、安全校验都涉及到了拿来当练手项目也挺合适。
返回列表