مراقبة التغييرات في الملفات (File Watching) وتنفيذ أوامر تلقائية


مرحباً بكم في درس جديد! اليوم، سنتعلم كيف نقوم بمراقبة التغييرات في الملفات والمجلدات باستخدام Node.js، ومن ثم تنفيذ أوامر تلقائية استجابةً لهذه التغييرات. هذه التقنية مفيدة جداً في مهام التطوير التلقائي، إعادة التحميل السريع، أو حتى أنظمة البناء.

الخطوة 1: مراقبة الملفات باستخدام fs.watch

تُعد وحدة fs (File System) في Node.js هي الأداة الأساسية للتعامل مع نظام الملفات. تحتوي هذه الوحدة على الدالة fs.watch() التي تسمح لنا بمراقبة ملف أو مجلد محدد للكشف عن أي تغييرات تطرأ عليه.

ملاحظة تقنية: دالة fs.watch() فعالة ولكن قد لا تكون دقيقة 100% على جميع أنظمة التشغيل، وقد تُطلق أحداثاً متعددة لتغيير واحد. لحلول أكثر قوة، يمكن استخدام مكتبات مثل chokidar.

لنبدأ بإنشاء سكربت بسيط يراقب ملفاً معيناً ويُبلغ عن أي تغيير فيه.

const fs = require('fs'); // استيراد وحدة نظام الملفات

const filePath = './test_file.txt'; // المسار إلى الملف الذي نريد مراقبته

console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">بدء مراقبة الملف: ${filePath}</code>);

// استخدام fs.watch لمراقبة التغييرات في الملف
fs.watch(filePath, (eventType, filename) => {
    // eventType يمكن أن يكون 'rename' أو 'change'
    // filename هو اسم الملف الذي تغير (قد يكون undefined في بعض الحالات)
    console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">تم الكشف عن حدث: ${eventType} في الملف: ${filename || filePath}</code>);
    if (eventType === 'change') {
        console.log('محتوى الملف قد تغير!');
        // هنا يمكن إضافة منطق إضافي للتعامل مع التغيير
    }
});

لتجربة هذا الكود، احفظه باسم watcher.js. ثم أنشئ ملفاً باسم test_file.txt في نفس المجلد. بعد تشغيل node watcher.js، حاول فتح test_file.txt وتعديل محتواه وحفظه. ستلاحظ أن السكربت يطبع رسالة في كل مرة تقوم فيها بحفظ الملف.

الخطوة 2: تنفيذ الأوامر الخارجية باستخدام child_process.exec

الآن بعد أن أصبح بإمكاننا اكتشاف التغييرات، نحتاج إلى طريقة لتنفيذ أوامر خارجية (مثل تشغيل سكربت آخر، أو أمر سطر أوامر) تلقائياً. لهذا الغرض، نستخدم وحدة child_process في Node.js، وبالتحديد الدالة exec().

const { exec } = require('child_process'); // استيراد دالة exec من وحدة child_process

// دالة مساعدة لتنفيذ أمر في سطر الأوامر
function executeCommand(command) {
    console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">تنفيذ الأمر: ${command}</code>);
    exec(command, (error, stdout, stderr) => {
        if (error) {
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ في تنفيذ الأمر: ${error.message}</code>);
            return;
        }
        if (stderr) {
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ قياسي (stderr): ${stderr}</code>);
            return;
        }
        console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">الإخراج القياسي (stdout):
${stdout}</code>);
    });
}

// مثال على كيفية استخدامها:
// executeCommand('echo "مرحباً من سطر الأوامر!"');
// executeCommand('ls -l'); // لعرض قائمة الملفات في المجلد الحالي

الآن، دعونا ندمج هذا مع مراقبة الملفات. سنقوم بمراقبة ملف my_script.js، وعندما يتغير، سنقوم بتشغيله باستخدام node my_script.js.

أولاً، أنشئ ملف my_script.js بسيطاً يحتوي على:

// my_script.js
console.log('مرحباً من my_script.js!');
console.log('الوقت الحالي:', new Date().toLocaleTimeString());

ثم قم بتعديل سكربت المراقبة الخاص بك ليصبح كالتالي:

const fs = require('fs');
const { exec } = require('child_process');

const watchedFilePath = './my_script.js'; // الملف الذي سنراقبه
const commandToExecute = <code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">node ${watchedFilePath}</code>; // الأمر الذي سينفذ

console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">بدء مراقبة الملف: ${watchedFilePath}</code>);

function executeWatchedScript() {
    console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">\n--- إعادة تشغيل السكربت ${watchedFilePath} ---</code>);
    exec(commandToExecute, (error, stdout, stderr) => {
        if (error) {
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ في تنفيذ السكربت: ${error.message}</code>);
            return;
        }
        if (stderr) {
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ قياسي (stderr) من السكربت:\n${stderr}</code>);
        }
        console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">الإخراج القياسي (stdout) من السكربت:\n${stdout}</code>);
    });
}

// تنفيذ السكربت لأول مرة عند بدء المراقبة
executeWatchedScript();

fs.watch(watchedFilePath, (eventType, filename) => {
    if (eventType === 'change') {
        console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">تم الكشف عن تغيير في ${filename || watchedFilePath}.</code>);
        executeWatchedScript(); // إعادة تشغيل السكربت عند التغيير
    }
});

عند تشغيل هذا السكربت وتعديل my_script.js وحفظه، ستلاحظ أن my_script.js يتم تنفيذه تلقائياً في كل مرة.

الخطوة 3: تحسين المراقبة وتجنب التنفيذ المتعدد (Debouncing)

كما ذكرنا سابقاً، قد تطلق fs.watch() أحداثاً متعددة لتغيير واحد (على سبيل المثال، عند حفظ ملف في بعض المحررات، قد يتم إصدار حدث "change" ثم "rename" أو عدة أحداث "change"). لتجنب تنفيذ الأمر عدة مرات بشكل غير ضروري، يمكننا استخدام تقنية تسمى "Debouncing".

تعتمد Debouncing على تأخير تنفيذ الأمر لفترة قصيرة، وإذا حدث أي حدث آخر خلال هذه الفترة، يتم إعادة تعيين المؤقت. هذا يضمن أن الأمر يتم تنفيذه مرة واحدة فقط بعد فترة من عدم وجود تغييرات.

const fs = require('fs');
const { exec } = require('child_process');

const watchedFilePath = './my_script.js';
const commandToExecute = <code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">node ${watchedFilePath}</code>;
const debounceDelay = 500; // 500 مللي ثانية للتأخير

let timeoutId; // متغير لتخزين معرف المؤقت

console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">بدء مراقبة الملف: ${watchedFilePath}</code>);

function executeWatchedScript() {
    console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">\n--- إعادة تشغيل السكربت ${watchedFilePath} ---</code>);
    exec(commandToExecute, (error, stdout, stderr) => {
        if (error) {
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ في تنفيذ السكربت: ${error.message}</code>);
            return;
        }
        if (stderr) {
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ قياسي (stderr) من السكربت:\n${stderr}</code>);
        }
        console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">الإخراج القياسي (stdout) من السكربت:\n${stdout}</code>);
    });
}

// تنفيذ السكربت لأول مرة عند بدء المراقبة
executeWatchedScript();

fs.watch(watchedFilePath, (eventType, filename) => {
    // التحقق من نوع الحدث، والتركيز على 'change' عادةً
    if (eventType === 'change') {
        console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">تم الكشف عن تغيير في ${filename || watchedFilePath}.</code>);

        // مسح المؤقت السابق إذا كان موجوداً لمنع التنفيذ المتعدد
        clearTimeout(timeoutId);

        // تعيين مؤقت جديد لتنفيذ الأمر بعد فترة تأخير
        timeoutId = setTimeout(() => {
            executeWatchedScript();
        }, debounceDelay);
    }
});

ملاحظة تقنية: يمكن تحسين آلية Debouncing بشكل أكبر للتعامل مع حالات خاصة مثل حذف الملف أو إعادة تسميته، ولكن هذا المثال يوفر أساساً قوياً للتعامل مع التغييرات المتكررة.

الكود النهائي الكامل

إليك السكربت الكامل الذي يجمع كل ما تعلمناه. تذكر أن تحتاج إلى ملف my_script.js في نفس المجلد.

const fs = require('fs');
const { exec } = require('child_process');

// المسار إلى الملف الذي نريد مراقبته
const watchedFilePath = './my_script.js';
// الأمر الذي سيتم تنفيذه عند اكتشاف تغيير في الملف
const commandToExecute = <code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">node ${watchedFilePath}</code>;
// فترة التأخير بالمللي ثانية لتجنب التنفيذ المتعدد (Debouncing)
const debounceDelay = 500;

let timeoutId; // لتخزين معرف المؤقت الخاص بـ Debouncing

console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">بدء مراقبة الملف: ${watchedFilePath}</code>);
console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">الأمر الذي سينفذ عند التغيير: ${commandToExecute}</code>);
console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">فترة التأخير (Debounce): ${debounceDelay}ms</code>);

/**
 * دالة لتنفيذ الأمر المحدد وطباعة مخرجاته.
 */
function executeWatchedScript() {
    console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">\n--- إعادة تشغيل السكربت ${watchedFilePath} ---</code>);
    exec(commandToExecute, (error, stdout, stderr) => {
        if (error) {
            // التعامل مع الأخطاء التي تحدث أثناء تنفيذ الأمر
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ في تنفيذ السكربت: ${error.message}</code>);
            return;
        }
        if (stderr) {
            // طباعة أي مخرجات على الخطأ القياسي (stderr)
            console.error(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">خطأ قياسي (stderr) من السكربت:\n${stderr}</code>);
        }
        // طباعة أي مخرجات على الإخراج القياسي (stdout)
        console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">الإخراج القياسي (stdout) من السكربت:\n${stdout}</code>);
    });
}

// تنفيذ السكربت لأول مرة عند بدء تشغيل أداة المراقبة
executeWatchedScript();

// بدء مراقبة الملف المحدد
fs.watch(watchedFilePath, (eventType, filename) => {
    // نركز على حدث 'change' لأنه الأكثر شيوعاً للتعديلات
    if (eventType === 'change') {
        console.log(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">\nتم الكشف عن تغيير في ${filename || watchedFilePath} (نوع الحدث: ${eventType}).</code>);

        // مسح المؤقت السابق إذا كان موجوداً لمنع التنفيذ المتعدد والسريع
        clearTimeout(timeoutId);

        // تعيين مؤقت جديد لتنفيذ الأمر بعد فترة تأخير
        // هذا يضمن أن الأمر لن يتم تنفيذه إلا بعد توقف التغييرات لفترة معينة
        timeoutId = setTimeout(() => {
            executeWatchedScript();
        }, debounceDelay);
    }
});

// رسالة للمستخدم لكيفية إيقاف السكربت
console.log('\nاضغط Ctrl+C لإيقاف أداة المراقبة.');

النتيجة المتوقعة

عند تشغيل السكربت watcher.js (الذي يحتوي على الكود النهائي الكامل) لأول مرة، سيقوم بتشغيل my_script.js مرة واحدة وستظهر مخرجاته على الشاشة. بعد ذلك، سيستمر في العمل في الخلفية. كلما قمت بتعديل وحفظ ملف my_script.js، ستلاحظ الآتي في طرفية Node.js:

  1. رسالة تشير إلى اكتشاف تغيير في الملف (مثال: تم الكشف عن تغيير في my_script.js (نوع الحدث: change).).
  2. بعد فترة تأخير قصيرة (500 مللي ثانية في مثالنا)، سيتم طباعة رسالة --- إعادة تشغيل السكربت my_script.js ---.
  3. ستظهر مخرجات تنفيذ my_script.js مرة أخرى، مما يدل على أنه تم تشغيله تلقائياً (مثال: مرحباً من my_script.js! و الوقت الحالي: [الوقت الحالي]).

هذا يوضح كيف يمكنك أتمتة مهام مثل إعادة تحميل الخادم، تشغيل اختبارات، أو إعادة بناء الأصول عند حفظ الملفات، مما يحسن بشكل كبير من سير عمل التطوير.