深入掌握scripts脚本的优化与实战技巧
项目初期的技术选型
这次接了个内部工具开发的活,目标是做一个轻量级的前端构建监控面板,用来实时看公司几个核心项目的打包状态、脚本执行进度和错误日志。本来这事儿后端就能搞定,但产品非要一个“有交互感”的页面,还得能手动触发某些 scripts 脚本。
我一开始想用现成的 CI/CD 界面套壳,比如 Jenkins 或 GitHub Actions 的 UI 改一改,结果发现定制成本太高,而且权限控制复杂。后来干脆自己搭个前端页面,通过 API 拉取任务列表,点击按钮触发远程 script 执行。
关键点在于:这些 scripts 是部署在内网服务器上的 shell 脚本,不能直接暴露给前端,所以得走后端代理。我这边只需要提供一个 UI 和调用逻辑,看起来简单,但实际上在实现过程中踩了不少坑,尤其是状态同步这块。
核心功能就这几行代码
整个页面其实很薄,主要就是一个任务列表 + 操作按钮 + 日志输出区域。最核心的部分就是点击“运行脚本”时发起请求,并轮询获取执行状态。
前端用的是 Vue 3 + Axios,后端是 Node.js 写的简单接口层,负责接收请求并 ssh 到目标机器执行脚本。
触发脚本的核心代码长这样:
async function runScript(taskId) {
try {
const res = await axios.post('https://jztheme.com/api/scripts/run', { taskId });
if (res.data.success) {
startPollingStatus(taskId); // 开始轮询
}
} catch (err) {
console.error('启动脚本失败', err);
}
}
轮询部分也没啥特别的:
let pollInterval;
function startPollingStatus(taskId) {
pollInterval = setInterval(async () => {
const res = await axios.get(https://jztheme.com/api/scripts/status/${taskId});
const { status, log } = res.data;
updateLogDisplay(log); // 更新日志展示
if (status === 'completed' || status === 'failed') {
clearInterval(pollInterval);
}
}, 2000);
}
HTML 结构也很朴素:
<div class="task-item" v-for="task in tasks">
<span>{{ task.name }}</span>
<button @click="runScript(task.id)" :disabled="task.running">
{{ task.status }}
</button>
<pre class="log-output">{{ task.log }}</pre>
</div>
看着挺顺,但真正上线测试才发现问题一堆。
最大的坑:状态不同步和日志延迟
第一个明显问题是:点完“运行”,界面显示“执行中”,但实际脚本没跑起来。查了半天才发现是后端接收请求后,还没来得及写入任务状态表,前端就开始轮询了,导致前几次请求都返回 pending,甚至 404。
开始我没意识到这是时序问题,还以为是网络延迟,折腾了半天加防抖、节流、重试机制,结果都没用。最后在日志里打印了时间戳才发现:前端第1次轮询发生在后端写入数据库之前。
解决办法很简单,但也挺土——在后端接口里加了个 waitForReady(),确保任务记录落地后再返回 success:
// 后端伪代码
app.post('/api/scripts/run', async (req, res) => {
const { taskId } = req.body;
await db.insertTask({ taskId, status: 'running', startTime: new Date() });
// 等待写入生效(避免前端轮询过快)
await sleep(500);
res.json({ success: true });
});
虽然丑,但有效。后来有人建议改成 WebSocket 推送启动成功事件,但我嫌改架构成本高,就没动。
第二个问题是日志输出不完整。我们原本设计是每次轮询把最新的日志全量拉回来,append 到 pre 标签里。结果发现有些日志会“跳变”——比如上次看到输出到第10行,下次突然只剩5行了。
排查发现是后端读取的日志文件被 rotate 过,或者脚本本身清屏了(比如用了 x1b[2J 清屏命令)。更坑的是,有的脚本中途会 truncate 自己的日志文件重新写,导致前端拿到的是“新日志”,旧内容全丢了。
这里我踩了好几次坑,试过只传增量、用 offset 标记位置、base64 编码比对内容……最后发现最简单的办法是:前端不要做任何合并或去重,每次轮询回来就直接 replace 整个 log 区域。
虽然用户体验上会有“闪一下”的感觉,但至少不会错。毕竟这是内部工具,没人真盯着看动画效果。
安全限制带来的麻烦
公司安全策略要求所有外部接口调用必须带 token,而且这个 token 有过期时间。我们一开始用的是长期有效的 service token,但被安全部门扫出来警告了一波,说不符合规范。
于是改成每次请求前先申请临时 token,缓存5分钟。听起来没问题,但实际运行中经常出现 token 失效导致脚本启动失败。
问题出在并发操作上:两个按钮同时点击,可能触发两次 get-token 请求,而鉴权服务不支持短时间内重复发放,直接返回 429。前端没处理好竞态,导致两个请求全都失败。
最后加了个简单的 token 管理器:
let currentToken = null;
let tokenPromise = null;
async function getValidToken() {
if (currentToken && !isExpired(currentToken)) {
return currentToken;
}
if (tokenPromise) {
return await tokenPromise;
}
tokenPromise = fetchNewToken();
try {
currentToken = await tokenPromise;
return currentToken;
} finally {
tokenPromise = null;
}
}
然后每个请求前都 await getValidToken(),算是治好了这个问题。不过这里其实还有优化空间,比如加 refresh 机制,但现在这样也能跑通,我就懒得动了。
最终的解决方案
上线前最后一天还遇到个诡异问题:某些机器上脚本明明执行完了,状态一直卡在 running。查了发现是后端监听脚本 exit code 的时候,因为信号中断没正确捕获,导致标记完成的逻辑没触发。
修法是在 shell 脚本末尾强制写一个状态文件:
# 脚本结尾加这一行
echo "status=completed" >> /var/log/script_status.log
后端不再完全依赖进程退出码,而是结合日志文件内容判断是否结束。虽然不优雅,但在混合使用 nohup、screen、docker exec 的环境下,反而更稳定。
最终整个系统的流程变成了这样:
- 用户点击按钮 → 前端请求启动脚本
- 后端写入任务记录,延迟500ms返回
- 前端开始每2秒轮询一次状态和日志
- 后端从日志文件读取当前输出,并检查是否有 completion 标记
- 一旦确认完成,停止轮询,更新UI
中间穿插 token 认证、错误重试、按钮禁用等细节处理。
回顾与反思
这个项目做了差不多三周,其实功能本身一周就能完,剩下两周全花在修各种边界情况上了。现在回头看,有几个做得还行的地方:
- 轮询间隔设成2秒而不是1秒,减少了服务器压力,也避免被限流
- 日志展示用 pre 标签原样输出,保留换行和颜色码,调试时特别有用
- 按钮点击后立刻置灰,防止重复提交,这个体验很重要
但也有一些明显可以优化的点:
- 应该上 WebSocket 替代轮询,尤其是当同时监控多个任务时,轮询太耗资源
- 日志搜索功能完全没有,现在只能靠浏览器 Ctrl+F,多人反馈不方便
- 没有权限控制,谁都能点“重启生产环境脚本”,纯靠自觉,有点悬
特别是最后一个,我一直想找时间加上 RBAC,但每次排期都被别的需求挤掉,到现在还是裸奔状态。好在目前使用者都是运维老手,还没出过大问题。
另外有个小问题一直没解决:移动端访问时,长时间轮询会导致页面卡顿,尤其是 Safari 上。试过用 setTimeout 替代 setInterval,也试过降低频率,但效果都不明显。后来干脆在移动端默认关闭自动轮询,让用户手动刷新,也算妥协方案吧。
以上是我的项目经验,希望对你有帮助
这个工具现在每天被用几十次,成了团队日常发布流程的一部分。虽然代码写得不算漂亮,有些地方甚至挺糙,但能跑通、不出大事,也算是合格了。
如果你也在做类似的脚本管理面板,我的建议是:别一开始就追求完美架构,先把最小闭环跑通,再根据实际问题一个个补洞。很多看似设计问题,其实是运行时才会暴露的环境差异。
以上是我踩坑后的总结,有更优的实现方式欢迎评论区交流。这类小工具的需求还挺常见的,后续我也会继续分享类似的实战经验。
