代码编辑器文件监听与自动保存完全指南

编辑器核心文件监听

文件监听与自动保存是现代代码编辑器的基础能力。当外部工具修改文件、Git切换分支或多个编辑器同时工作时,编辑器需要及时感知变更并做出正确响应。本文详细介绍文件监听机制、自动保存策略、冲突处理与性能优化的完整实现。

文件监听架构

监听服务设计

// 文件监听服务
class FileWatcherService {
    constructor() {
        this.watchers = new Map();      // uri -> watcher
        this.listeners = new Map();     // event -> callback[]
        this.debounceTimers = new Map(); // uri -> timer
        this.throttleMs = 100;
        this.excludes = new Set([
            '**/node_modules/**',
            '**/.git/**',
            '**/dist/**',
            '**/__pycache__/**'
        ]);
    }

    watch(uri, options = {}) {
        const path = uriToPath(uri);

        // 检查排除规则
        if (this.isExcluded(path)) return;

        // 已存在则跳过
        if (this.watchers.has(uri)) return;

        // 创建原生监听
        const watcher = createNativeWatcher(path, {
            recursive: options.recursive ?? true,
            ignoreInitial: true,
            ignored: this.excludes
        });

        watcher.on('change', (eventType, filename) => {
            this.handleFileChange(uri, eventType, filename);
        });

        watcher.on('error', (err) => {
            this.emit('error', { uri, error: err });
        });

        this.watchers.set(uri, watcher);
    }

    unwatch(uri) {
        const watcher = this.watchers.get(uri);
        if (watcher) {
            watcher.close();
            this.watchers.delete(uri);
        }
    }
}

原生监听适配

// 多平台文件监听适配
function createNativeWatcher(path, options) {
    // Linux: inotify (通过 fs.watch)
    // macOS: FSEvents (通过 fs.watch)
    // Windows: ReadDirectoryChangesW (通过 fs.watch)

    const fs = require('fs');
    const watcher = fs.watch(path, {
        recursive: options.recursive,
        persistent: true,
        encoding: 'utf8'
    });

    const emitter = new EventEmitter();

    watcher.on('change', (eventType, filename) => {
        if (!filename) return;

        const fullPath = join(path, filename);

        // 过滤临时文件
        if (isTempFile(filename)) return;

        emitter.emit('change', {
            type: eventType,  // 'rename' | 'change'
            path: fullPath,
            timestamp: Date.now()
        });
    });

    watcher.on('error', (err) => {
        emitter.emit('error', err);
    });

    return {
        on(event, cb) { emitter.on(event, cb); },
        close() { watcher.close(); }
    };
}

function isTempFile(filename) {
    const patterns = [
        /\.swp$/,          // Vim swap
        /\.tmp$/,          // 临时文件
        /~$/,              // 备份文件
        /\.bak$/,          // 备份
        /^\./,             // 隐藏文件(部分)
        /4913$/            // Vim 特殊文件
    ];
    return patterns.some(p => p.test(filename));
}

变更事件处理

防抖与合并

// 文件变更防抖处理
class ChangeEventAggregator {
    constructor(delayMs = 100) {
        this.delayMs = delayMs;
        this.pendingEvents = new Map();
        this.timer = null;
    }

    addEvent(event) {
        const key = event.path;

        if (this.pendingEvents.has(key)) {
            // 合并同文件事件:取最新状态
            const existing = this.pendingEvents.get(key);
            existing.type = event.type;  // 保留最新事件类型
            existing.timestamp = event.timestamp;
        } else {
            this.pendingEvents.set(key, { ...event });
        }

        // 重置定时器
        if (this.timer) clearTimeout(this.timer);
        this.timer = setTimeout(() => this.flush(), this.delayMs);
    }

    flush() {
        const events = [...this.pendingEvents.values()];
        this.pendingEvents.clear();
        this.timer = null;

        for (const event of events) {
            this.emit('change', event);
        }
    }
}

文件状态检测

// 检测文件变更类型
class FileStateChecker {
    constructor() {
        this.states = new Map(); // uri -> { mtime, size, hash }
    }

    async checkChange(uri) {
        const path = uriToPath(uri);
        const oldState = this.states.get(uri);

        try {
            const stat = await fsPromises.stat(path);

            if (!oldState) {
                // 新文件
                this.states.set(uri, {
                    mtime: stat.mtimeMs,
                    size: stat.size
                });
                return { type: 'created' };
            }

            // 检查是否真正变化
            if (stat.mtimeMs === oldState.mtime &&
                stat.size === oldState.size) {
                return { type: 'unchanged' };
            }

            const oldSize = oldState.size;
            oldState.mtime = stat.mtimeMs;
            oldState.size = stat.size;

            if (stat.size === 0) {
                return { type: 'deleted' };
            }

            return {
                type: 'modified',
                sizeChanged: oldSize !== stat.size
            };
        } catch (err) {
            if (err.code === 'ENOENT') {
                this.states.delete(uri);
                return { type: 'deleted' };
            }
            return { type: 'error', error: err };
        }
    }
}

自动保存

自动保存策略

// 自动保存管理器
class AutoSaveManager {
    constructor(documentManager) {
        this.docManager = documentManager;
        this.config = {
            enabled: true,
            delay: 1000,           // 延迟1秒
            afterDelay: true,    // 延迟保存
            onFocusChange: true, // 焦点变更时保存
            // onWindowChange: true   // 窗口切换时保存
        };
        this.saveTimers = new Map();
    }

    onDocumentChange(uri) {
        if (!this.config.enabled || !this.config.afterDelay) {
            return;
        }

        // 清除之前的定时器
        if (this.saveTimers.has(uri)) {
            clearTimeout(this.saveTimers.get(uri));
        }

        // 延迟保存
        this.saveTimers.set(uri, setTimeout(async () => {
            await this.saveDocument(uri);
            this.saveTimers.delete(uri);
        }, this.config.delay));
    }

    async saveDocument(uri) {
        const doc = this.docManager.getDocument(uri);
        if (!doc || !doc.isDirty) return;

        try {
            // 先写入临时文件,再原子替换
            const path = uriToPath(uri);
            const tmpPath = path + '.tmp-save';

            await fsPromises.writeFile(tmpPath, doc.getText(), 'utf8');
            await fsPromises.rename(tmpPath, path);

            doc.markClean();
            this.emit('saved', { uri });
        } catch (err) {
            this.emit('saveError', { uri, error: err });
        }
    }

    onFocusChange() {
        if (!this.config.enabled || !this.config.onFocusChange) {
            return;
        }

        // 保存所有脏文档
        for (const [uri, doc] of this.docManager.documents) {
            if (doc.isDirty) {
                this.saveDocument(uri);
            }
        }
    }
}

冲突处理

外部变更冲突

// 冲突检测与处理
class ConflictResolver {
    constructor(documentManager) {
        this.docManager = documentManager;
        this.diskVersions = new Map();  // uri -> disk content hash
    }

    async handleExternalChange(uri) {
        const doc = this.docManager.getDocument(uri);
        if (!doc) return;

        const diskContent = await readFileContent(uri);
        const diskHash = hashContent(diskContent);

        // 情况1: 编辑器无未保存修改 → 直接加载磁盘版本
        if (!doc.isDirty) {
            doc.setText(diskContent);
            this.diskVersions.set(uri, diskHash);
            return;
        }

        // 情况2: 编辑器有未保存修改 → 冲突
        const lastDiskHash = this.diskVersions.get(uri);
        const editorHash = hashContent(doc.getText());

        // 检查磁盘内容是否和编辑器内容相同
        if (diskHash === editorHash) {
            doc.markClean();
            this.diskVersions.set(uri, diskHash);
            return;
        }

        // 真正的冲突 → 显示选择界面
        this.showConflictDialog(uri, {
            editorContent: doc.getText(),
            diskContent: diskContent,
            options: [
                { label: '保留编辑器版本', action: 'keep' },
                { label: '加载磁盘版本', action: 'load' },
                { label: '对比差异', action: 'diff' }
            ]
        });
    }

    showConflictDialog(uri, conflict) {
        // 弹出用户选择对话框
        this.emit('conflict', {
            uri,
            message: `文件已被外部修改: ${uriToPath(uri)}`,
            ...conflict
        });
    }

    async resolveConflict(uri, action) {
        const doc = this.docManager.getDocument(uri);
        const diskContent = await readFileContent(uri);

        switch (action) {
            case 'keep':
                // 保留编辑器版本,下次保存时覆盖磁盘
                break;
            case 'load':
                doc.setText(diskContent);
                doc.markClean();
                break;
            case 'diff':
                // 打开对比视图
                this.emit('openDiff', { uri, diskContent });
                break;
        }
    }
}

性能优化

大量文件监听

// 大型项目文件监听优化
class OptimizedWorkspaceWatcher {
    constructor() {
        this.watchers = [];
        this.fileEvents = [];     // 事件队列
        this.maxWatchers = 5000; // 监听上限
    }

    async startWatching(workspaceRoot) {
        // 1. 读取 .gitignore 规则
        const ignoreRules = await loadGitignore(workspaceRoot);

        // 2. 统计文件数量
        const fileCount = await countWatchableFiles(
            workspaceRoot, ignoreRules
        );

        if (fileCount > this.maxWatchers) {
            // 3. 超出限制: 使用轮询模式
            this.startPolling(workspaceRoot, ignoreRules);
        } else {
            // 4. 正常: 原生监听
            this.startNativeWatch(workspaceRoot, ignoreRules);
        }
    }

    startPolling(root, ignoreRules) {
        // 低频率轮询,减轻系统负担
        const interval = 5000; // 5秒
        let lastScan = new Map();

        const poll = async () => {
            const currentScan = await scanDirectory(root, ignoreRules);

            for (const [path, stat] of currentScan) {
                const prev = lastScan.get(path);
                if (!prev) {
                    this.emit('created', { path });
                } else if (prev.mtime !== stat.mtime) {
                    this.emit('changed', { path });
                }
            }

            for (const [path] of lastScan) {
                if (!currentScan.has(path)) {
                    this.emit('deleted', { path });
                }
            }

            lastScan = currentScan;
        };

        this.pollTimer = setInterval(poll, interval);
        poll(); // 立即执行一次
    }
}

总结

  • 文件监听 - 基于原生API实现,支持inotify/FSEvents/ReadDirectoryChangesW
  • 事件防抖 - 合并短时间内的多次变更事件,避免重复处理
  • 自动保存 - 延迟保存、焦点变更保存、原子写入确保数据安全
  • 冲突处理 - 检测外部修改,提供保留/加载/对比三种解决策略
  • 性能优化 - 大型项目自动切换轮询模式,限制监听数量
  • 临时文件过滤 - 忽略编辑器临时文件和系统隐藏文件

文件监听与自动保存直接影响编辑器的可靠性和用户体验,需要在实时性与性能之间取得平衡。