作为天天在终端里泡着的前端er,我手头有个痛点忍了很久了:每次改完静态文件,得手动scp到服务器,或者开FileZilla拖拽。效率低不说,还容易漏文件。尤其是赶项目上线那会儿,频繁改个小图标都要等半分钟传输,体验极差。
于是我花了两晚上,写了个文件同步CLI工具,叫filesync。思路很简单------监听本地目录变化,增量同步到远程服务器。整个工具大概400行代码,今天把核心逻辑拆解一下,给有类似需求的同学参考。
先说技术选型
Node.js写CLI工具是真的舒服,生态丰富,上手快。我调研了几个关键包:
chokidar:监听文件变化,比原生fs.watch稳得多,跨平台兼容好ssh2:SSH2客户端库,Node里做SFTP最靠谱的选择fs-extra:文件操作封装,比原生fs顺手clikt或commander:命令行参数解析,我选了commander
核心就这四个,没有引入太复杂的依赖。
目录结构
python
filesync/
├── bin/
│ └── filesync.js # CLI入口
├── src/
│ ├── index.js # 主逻辑
│ ├── sync.js # 同步核心
│ └── watcher.js # 监听逻辑
├── package.json
└── README.md
bin/filesync.js非常简洁:
javascript
#!/usr/bin/env node
const { program } = require('commander');
const { watch } = require('./src/watcher');
const { sync } = require('./src/sync');
program
.option('-l, --local <path>', '本地目录', process.cwd())
.option('-r, --remote <path>', '远程目录')
.option('-h, --host <host>', '服务器地址')
.option('-p, --port <port>', '端口', '22')
.option('-u, --user <user>', '用户名')
.option('-k, --key <path>', '私钥路径')
.parse(process.argv);
const opts = program.opts();
// 单次同步
if (opts.remote && opts.host) {
sync(opts).then(() => {
console.log('同步完成');
process.exit(0);
});
} else {
// 监听模式
watch(opts);
}
用法上,支持两种模式。一种是单次同步:
bash
filesync -l ./dist -r /var/www/app -h 101.132.45.23 -u root -k ~/.ssh/id_rsa
另一种是监听模式,文件变动自动触发:
bash
filesync -l ./dist -r /var/www/app -h 101.132.45.23 -u root -k ~/.ssh/id_rsa
文件监听:watcher.js
这是第一块核心代码。我之前用过fs.watchFile,CPU占用高得吓人,换成chokidar之后稳多了。
javascript
const chokidar = require('chokidar');
const { sync } = require('./sync');
let pending = new Map();
let timer = null;
function watch(opts) {
const { local } = opts;
console.log(`监听 ${local} 目录变化...`);
const watcher = chokidar.watch(local, {
ignored: /(^|[\/\\])\../, // 忽略隐藏文件
persistent: true,
ignoreInitial: true, // 忽略初始扫描
awaitWriteFinish: { // 等待写入完成
stabilityThreshold: 300,
pollInterval: 100
}
});
watcher
.on('add', path => handleChange('add', path, opts))
.on('change', path => handleChange('change', path, opts))
.on('unlink', path => handleChange('unlink', path, opts));
}
function handleChange(event, path, opts) {
const key = `${event}:${path}`;
pending.set(key, Date.now());
// 300ms内的多次变动,合并为一次同步
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
const ops = Array.from(pending.entries());
pending.clear();
for (const [key, time] of ops) {
const [event, filePath] = key.split(':');
console.log(`[${event}] ${filePath}`);
await sync({ ...opts, files: [filePath] });
}
}, 300);
}
module.exports = { watch };
有个细节我踩过坑:Node.js的fs.watch和chokidar都存在"写入中"检测问题。图片或者大文件还在写入,chokidar就触发change事件了。解决方案是加awaitWriteFinish,等文件稳定300ms再处理。这个数值是我本地测试出来的,文件越大可能需要调整到500ms甚至更高。
SFTP同步:sync.js
增量的精髓在于只传变化的文件,不做全量覆盖。SFTP连接这块我封装了一个工厂函数:
javascript
const Client = require('ssh2').Client;
const fs = require('fs-extra');
const path = require('path');
const { SftpSync } = require('./sftp-wrapper');
async function sync(opts) {
const { local, remote, host, port, user, key, files } = opts;
const client = new Client();
await new Promise((resolve, reject) => {
client.connect({
host,
port: parseInt(port),
username: user,
privateKey: fs.readFileSync(key)
});
client.on('ready', resolve).on('error', reject);
});
const sftp = new SftpSync(client);
for (const file of files) {
const relativePath = path.relative(local, file);
const remotePath = path.posix.join(remote, relativePath);
if (fs.statSync(file).isDirectory()) {
await sftp.mkdir(remotePath, { recursive: true });
} else {
await sftp.put(file, remotePath);
console.log(` -> ${remotePath}`);
}
}
client.end();
}
module.exports = { sync };
SFTP的包装类我单独拆了出来,因为涉及到连接管理和队列控制:
javascript
class SftpSync {
constructor(client) {
this.sftp = null;
this.client = client;
this.queue = Promise.resolve();
}
getSftp() {
if (!this.sftp) {
this.sftp = new Promise((resolve, reject) => {
this.client.sftp((err, sftp) => {
if (err) reject(err);
else resolve(sftp);
});
});
}
return this.sftp;
}
async put(localPath, remotePath) {
await this.getSftp();
const sftp = await this.sftp;
return new Promise((resolve, reject) => {
sftp.fastPut(localPath, remotePath, {}, (err) => {
if (err) reject(err);
else resolve();
});
});
}
async mkdir(remotePath, opts = {}) {
await this.getSftp();
const sftp = await this.sftp;
return new Promise((resolve, reject) => {
sftp.mkdir(remotePath, opts, (err) => {
// 目录已存在不报错
if (err && err.code !== 4) reject(err);
else resolve();
});
});
}
}
这里用Promise链做队列控制,避免并发写入同一个文件导致损坏。每次put操作都会加到队列尾部,串行执行。
远程路径映射的坑
做了才知道,Windows和Linux的路径格式不一样。Windows是\,Linux是/。写的时候直接用path.join会出问题,比如本地路径dist\assets\logo.png传到服务器变成dist\assets\logo.png,Linux根本识别不了。
解决方案是用path.posix.join处理所有远程路径:
javascript
const remotePath = path.posix.join(remote, path.relative(local, file));
这样无论本地是Windows还是Mac,远程路径永远是Unix风格。
测试过程
我拿一个Vue3项目测试,dist目录大概50MB,200多个文件。第一次全量同步花了12秒,后续单文件修改平均80ms触达。测试场景如下:
- 修改单个JS文件:平均耗时150ms(含监听延迟300ms + 传输 + SSH握手)
- 修改单个图片:平均耗时300ms
- 连续修改多个文件:合并为一次同步,大概500ms
CPU占用方面,监听模式下chokidar稳定在0.5%以下,SSH连接占用1%左右。内存 footprint 非常小,我的Mac Air M1跑起来毫无压力。
还能怎么改进
现在这版还有很多可以优化的地方:
- 增量对比:目前只对比文件名和时间戳,没有MD5校验,大文件可能会有误差
- 断点续传:大文件传一半断了就得重来
- 多端同步:一个源同步到多台服务器
- 压缩传输:开启SSH压缩,减少带宽占用
这些功能我在规划里了,有时间慢慢加上。
结语
整个工具核心逻辑就这些,代码量不大,但解决了我的实际问题。写这个的收获是:日常开发中那些让你皱眉的重复操作,都值得花时间自动化。工具写好之后,注意力才能真正放回业务逻辑上。
完整代码我放到了GitHub,有兴趣的可以参考:github.com/xxx/filesync。有问题欢迎提Issue,觉得有用的话给个star就更好了。