كتابة Plugin

تفتح plugins كامل قدرات محرك webpack أمام مطوري الطرف الثالث. باستخدام callbacks الخاصة بمراحل build، يستطيع المطورون إدخال سلوكياتهم الخاصة داخل عملية build في webpack. كتابة plugins أكثر تقدمًا قليلًا من كتابة loaders؛ لأنك ستحتاج إلى فهم بعض التفاصيل الداخلية منخفضة المستوى في webpack حتى تعرف أين تدخل. كن مستعدًا لقراءة جزء من الكود المصدري.

إنشاء Plugin

يتكون plugin في webpack من:

  • دالة JavaScript لها اسم، أو صنف JavaScript.
  • يعرّف دالة apply على prototype الخاص به.
  • يحدد event hook للدخول إليه.
  • يتعامل مع بيانات داخلية خاصة بـ instance في webpack.
  • يستدعي callback التي يوفرها webpack بعد اكتمال عمله.
// صنف JavaScript.
class MyExampleWebpackPlugin {
  // عرّف `apply` كدالة على prototype، وتستقبل compiler كـ argument
  apply(compiler) {
    // الدخول إلى compilation
    compiler.hooks.thisCompilation.tap(
      "MyExampleWebpackPlugin",
      (compilation) => {
        // تحديد hook الخاص بمعالجة assets
        compilation.hooks.processAssets.tap(
          {
            name: "MyExampleWebpackPlugin",
            stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
          },
          (assets) => {
            console.log("This is an example plugin!");
            console.log(
              "Here’s the `compilation` object which represents a single build of assets:",
              compilation,
            );
          },
        );
      },
    );
  }
}

البنية الأساسية للـ plugin

الـ plugins هي كائنات يتم إنشاء instance منها، وتحتوي على دالة apply في prototype. يستدعي webpack compiler هذه الدالة مرة واحدة أثناء تثبيت plugin. تحصل دالة apply على reference إلى compiler الأساسي في webpack، وهذا يمنحها وصولًا إلى compiler callbacks. تكون بنية plugin عادةً بهذا الشكل:

class HelloWorldPlugin {
  apply(compiler) {
    // يعمل هذا hook عندما تكتمل عملية build بالكامل
    compiler.hooks.done.tap(
      "Hello World Plugin",
      (stats /* يتم تمرير stats كـ argument عند استخدام done hook.  */) => {
        console.log("Hello World!");
      },
    );
  }
}

export default HelloWorldPlugin;

لاستخدام plugin، أضف instance منه داخل array المسماة plugins في إعدادات webpack:

// webpack.config.js
import HelloWorldPlugin from "hello-world";

export default {
  // ... إعداداتك هنا ...
  plugins: [new HelloWorldPlugin({ options: true })],
};

الطريقة المعتادة للتحقق من خيارات plugin في إصدارات webpack قبل 5.106 هي استخدام schema-utils داخل constructor:

import { validate } from "schema-utils";

// schema لكائن options
const schema = {
  type: "object",
  properties: {
    test: {
      type: "string",
    },
  },
};

export default class HelloWorldPlugin {
  constructor(options = {}) {
    validate(schema, options, {
      name: "Hello World Plugin",
      baseDataPath: "options",
    });
  }

  apply(compiler) {}
}

ابتداءً من webpack 5.106، يمكنك نقل التحقق إلى apply() عبر الدخول إلى compiler.hooks.validate واستدعاء compiler.validate(...) بدلًا من ذلك:

export default class HelloWorldPlugin {
  constructor(options = {}) {
    this.options = options;
  }

  apply(compiler) {
    compiler.hooks.validate.tap("HelloWorldPlugin", () => {
      compiler.validate(
        () => require("./schema/hello-world-plugin.json"),
        this.options,
      );
    });
  }
}

Compiler وCompilation

من أهم الموارد أثناء تطوير plugins كائنان: compiler وcompilation. فهم دور كل واحد منهما خطوة أولى مهمة لتوسيع محرك webpack.

class HelloCompilationPlugin {
  apply(compiler) {
    // الدخول إلى compilation hook الذي يمرر compilation كـ argument إلى callback
    compiler.hooks.compilation.tap("HelloCompilationPlugin", (compilation) => {
      // الآن يمكننا الدخول إلى hooks المتاحة عبر compilation
      compilation.hooks.optimize.tap("HelloCompilationPlugin", () => {
        console.log("Assets are being optimized.");
      });
    });
  }
}

export default HelloCompilationPlugin;

للحصول على قائمة hooks المتاحة على compiler وcompilation والكائنات المهمة الأخرى، راجع توثيق plugins API.

Event hooks غير متزامنة

بعض plugin hooks غير متزامنة. للدخول إليها يمكن استخدام tap، والتي تتصرف بشكل متزامن، أو استخدام tapAsync أو tapPromise، وهما طريقتان غير متزامنتين.

tapAsync

عند استخدام tapAsync للدخول إلى plugins، يجب استدعاء دالة callback التي تُمرر كآخر argument إلى دالتنا.

class HelloAsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(
      "HelloAsyncPlugin",
      (compilation, callback) => {
        // نفّذ عملًا غير متزامن...
        setTimeout(() => {
          console.log("Done with async work...");
          callback();
        }, 1000);
      },
    );
  }
}

export default HelloAsyncPlugin;

tapPromise

عند استخدام tapPromise للدخول إلى plugins، يجب إرجاع Promise يتم حلّها عند اكتمال المهمة غير المتزامنة.

class HelloAsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapPromise(
      "HelloAsyncPlugin",
      (compilation) =>
        // أرجع Promise تُحل عندما ننتهي...
        new Promise((resolve, reject) => {
          setTimeout(() => {
            console.log("Done with async work...");
            resolve();
          }, 1000);
        }),
    );
  }
}

export default HelloAsyncPlugin;

مثال

بعد أن نستطيع الإمساك بـ webpack compiler وبكل compilation منفردة، تصبح الاحتمالات كثيرة جدًا. يمكننا إعادة تنسيق ملفات موجودة، أو إنشاء ملفات مشتقة، أو توليد assets جديدة بالكامل.

سنكتب مثال plugin ينشئ ملف build جديدًا باسم assets.md، ويحتوي هذا الملف على قائمة بكل ملفات assets الموجودة في build. قد يبدو plugin بهذا الشكل:

class FileListPlugin {
  static defaultOptions = {
    outputFile: "assets.md",
  };

  // يجب تمرير أي options عبر constructor الخاص بالـ plugin،
  // فهذا جزء من public API للـ plugin.
  constructor(options = {}) {
    // تطبيق options التي حددها المستخدم فوق options الافتراضية،
    // وإتاحة options المدمجة لاحقًا لدوال plugin.
    // غالبًا يجب أن تتحقق من كل options هنا أيضًا.
    this.options = { ...FileListPlugin.defaultOptions, ...options };
  }

  apply(compiler) {
    const pluginName = FileListPlugin.name;

    // يمكن الوصول إلى instance الخاصة بـ webpack module من كائن compiler،
    // وهذا يضمن استخدام الإصدار الصحيح من module
    // لا تستخدم require/import لـ webpack أو أي رموز منه مباشرة.
    const { webpack } = compiler;

    // يعطينا كائن Compilation reference إلى ثوابت مفيدة.
    const { Compilation } = webpack;

    // RawSource أحد أصناف "sources" التي يجب استخدامها
    // لتمثيل مصادر assets داخل compilation.
    const { RawSource } = webpack.sources;

    // الدخول إلى hook "thisCompilation" حتى ندخل لاحقًا
    // إلى عملية compilation في مرحلة مبكرة.
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      // الدخول إلى مسار معالجة assets في مرحلة محددة.
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,

          // استخدام مرحلة متأخرة من مراحل معالجة assets للتأكد
          // من أن كل assets أُضيفت بالفعل إلى compilation بواسطة plugins أخرى.
          stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          // "assets" هو object يحتوي على كل assets
          // داخل compilation. مفاتيح object هي مسارات assets
          // والقيم هي مصادر الملفات.

          // المرور على كل assets
          // وتوليد محتوى ملف Markdown.
          const content = `# In this build:\n\n${Object.keys(assets)
            .map((filename) => `- ${filename}`)
            .join("\n")}`;

          // إضافة asset جديد إلى compilation، حتى يولده webpack
          // تلقائيًا داخل مجلد output.
          compilation.emitAsset(
            this.options.outputFile,
            new RawSource(content),
          );
        },
      );
    });
  }
}

export default FileListPlugin;

webpack.config.js

import FileListPlugin from "./file-list-plugin.js";

// استخدم plugin داخل إعدادات webpack:
export default {
  // …

  plugins: [
    // إضافة plugin بالخيارات الافتراضية
    new FileListPlugin(),

    // أو:

    // يمكنك تمرير أي options مدعومة:
    new FileListPlugin({
      outputFile: "my-assets.md",
    }),
  ],
};

سيولّد هذا ملف Markdown بالاسم الذي اخترته، وسيبدو مثل:

# In this build:

- main.css
- main.js
- index.html

مراقبة تغييرات الملفات

عندما يعمل webpack في watch mode، مثل webpack --watch أو webpack serve، فإنه ينشئ compilation جديدًا لكل rebuild يحدث بسبب تغييرات الملفات.

تسمح مجموعة compiler.modifiedFiles للـ plugin بمعرفة الملفات المحددة التي سببت rebuild، وبالتالي يمكنك تجاوز الأعمال المكلفة عندما تكون الملفات المتغيرة غير مهمة للـ plugin.

هذا مفيد للـ plugins التي تحتاج إلى التفاعل مع تغييرات ملفات معينة فقط، مثل إعادة توليد assets أو إعادة معالجة templates أو إبطال caches.

class WatchNotifierPlugin {
  apply(compiler) {
    compiler.hooks.watchRun.tap("WatchNotifierPlugin", (compiler) => {
      if (compiler.modifiedFiles) {
        const changedFiles = [...compiler.modifiedFiles]
          .map((file) => `${file}`)
          .join("\n");

        console.log(`\nFiles changed:\n${changedFiles}`);
      }
    });
  }
}

export default WatchNotifierPlugin;

ملاحظة:

  • compiler.modifiedFiles هي Set وليست array.
  • تكون undefined في أول build بارد.
  • لا تُملأ إلا أثناء watch rebuilds.

إضافة file dependencies مخصصة

إذا كان plugin يقرأ ملفات خارجية، مثل ملفات إعدادات أو templates، وwebpack لا يتتبعها افتراضيًا، فيجب أن تخبر webpack بمراقبتها.

يمكنك إخبار webpack بمراقبة أنواع مختلفة من dependencies:

  • تُستخدم compilation.fileDependencies لتتبع ملفات فردية يعتمد عليها plugin، حتى يستطيع webpack تشغيل rebuild عندما تتغير هذه الملفات.

  • تُستخدم compilation.contextDependencies لمراقبة مجلدات، بحيث يؤدي أي تغيير داخلها إلى rebuild.

  • تُستخدم compilation.missingDependencies لتتبع ملفات غير موجودة حاليًا، حتى يستطيع webpack تشغيل rebuild عند إنشائها.

import path from "node:path";

class TemplateWatchPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap("TemplateWatchPlugin", (compilation) => {
      const templatePath = path.resolve(__dirname, "my-template.html");

      // تأكد من أن webpack يراقب هذا الملف
      compilation.fileDependencies.add(templatePath);

      // راقب مجلدًا كاملًا context dependency
      const templatesDir = path.resolve(__dirname, "templates");
      compilation.contextDependencies.add(templatesDir);

      // مثال: علّم dependency مفقودًا
      const missingFile = path.resolve(__dirname, "missing-file.txt");
      compilation.missingDependencies.add(missingFile);
    });
  }
}

export default TemplateWatchPlugin;

بدون استدعاء fileDependencies.add()، لن يشغّل webpack rebuild عند تغير الملف، حتى لو كان plugin يعتمد عليه.

أشكال Plugins المختلفة

يمكن تصنيف plugin إلى أنواع بناءً على event hooks التي يدخل إليها. كل event hook يكون معرّفًا مسبقًا كـ hook متزامن أو غير متزامن أو waterfall أو parallel، ويتم استدعاؤه داخليًا باستخدام call أو callAsync. عادةً تكون قائمة hooks المدعومة أو القابلة للدخول موجودة في الخاصية this.hooks.

مثال:

this.hooks = {
  shouldEmit: new SyncBailHook(["compilation"]),
};

هذا يعني أن hook الوحيد المدعوم هو shouldEmit، وهو من نوع SyncBailHook، وأن parameter الوحيد الذي سيُمرر لأي plugin يدخل إلى shouldEmit هو compilation.

أنواع hooks المدعومة متعددة:

Hooks متزامنة

  • SyncHook

    • تُعرّف بالشكل new SyncHook([params]).
    • يتم الدخول إليها باستخدام tap.
    • تُستدعى باستخدام call(...params).
  • Bail Hooks

    • تُعرّف باستخدام SyncBailHook[params].
    • يتم الدخول إليها باستخدام tap.
    • تُستدعى باستخدام call(...params).

    في هذا النوع من hooks، تُستدعى callbacks الخاصة بكل plugin واحدًا تلو الآخر مع args المحددة. إذا أرجع أي plugin أي قيمة غير undefined، فترجع hook هذه القيمة ولا يتم استدعاء callbacks اللاحقة. أحداث مفيدة كثيرة مثل optimizeChunks وoptimizeChunkModules هي SyncBailHooks.

  • Waterfall Hooks

    • تُعرّف باستخدام SyncWaterfallHook[params].
    • يتم الدخول إليها باستخدام tap.
    • تُستدعى باستخدام call(...params).

    هنا يُستدعى كل plugin واحدًا تلو الآخر، وتأتي arguments من القيمة التي أرجعها plugin السابق. يجب أن يأخذ plugin ترتيب تنفيذه بالحسبان، وأن يقبل arguments القادمة من plugin السابق. قيمة أول plugin هي init. لذلك يجب تمرير parameter واحد على الأقل لـ waterfall hooks. يُستخدم هذا النمط في instances الخاصة بـ Tapable المرتبطة بقوالب webpack مثل ModuleTemplate وChunkTemplate وغيرها.

Hooks غير متزامنة

  • Async Series Hook

    • تُعرّف باستخدام AsyncSeriesHook[params].
    • يتم الدخول إليها باستخدام tap أو tapAsync أو tapPromise.
    • تُستدعى باستخدام callAsync(...params).

    تُستدعى دوال handlers الخاصة بـ plugin مع كل arguments ومع callback بالشكل (err?: Error) -> void. تُستدعى دوال handlers حسب ترتيب تسجيلها. ويتم استدعاء callback بعد استدعاء كل handlers. هذا نمط شائع أيضًا لأحداث مثل emit وrun.

  • Async waterfall يتم تطبيق plugins بشكل غير متزامن بطريقة waterfall.

    • تُعرّف باستخدام AsyncWaterfallHook[params].
    • يتم الدخول إليها باستخدام tap أو tapAsync أو tapPromise.
    • تُستدعى باستخدام callAsync(...params).

    تُستدعى دوال handlers الخاصة بـ plugin مع القيمة الحالية ومع callback بالشكل (err: Error, nextValue: any) -> void. عند الاستدعاء، تكون nextValue هي القيمة الحالية للـ handler التالي. والقيمة الحالية لأول handler هي init. بعد تطبيق كل handlers، تُستدعى callback بالقيمة الأخيرة. إذا مرر أي handler قيمة لـ err، تُستدعى callback بهذا الخطأ ولا يتم استدعاء بقية handlers. يُتوقع هذا النمط لأحداث مثل before-resolve وafter-resolve.

  • Async Series Bail

    • تُعرّف باستخدام AsyncSeriesBailHook[params].
    • يتم الدخول إليها باستخدام tap أو tapAsync أو tapPromise.
    • تُستدعى باستخدام callAsync(...params).
  • Async Parallel

    • تُعرّف باستخدام AsyncParallelHook[params].
    • يتم الدخول إليها باستخدام tap أو tapAsync أو tapPromise.
    • تُستدعى باستخدام callAsync(...params).

القيم الافتراضية للإعدادات

يطبق webpack القيم الافتراضية للإعدادات بعد تطبيق القيم الافتراضية الخاصة بـ plugins. هذا يسمح للـ plugins بتوفير defaults خاصة بها، ويوفر طريقة لإنشاء configuration preset plugins.

مثال: AssetLoggerPlugin

يوضح المثال التالي plugin بسيطًا في webpack يسجل كل assets المولدة باستخدام واجهة logger في webpack.

class AssetLoggerPlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap("AssetLoggerPlugin", (compilation) => {
      const logger = compilation.getLogger("AssetLoggerPlugin");
      compilation.hooks.processAssets.tap(
        {
          name: "AssetLoggerPlugin",
          stage: compilation.constructor.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        (assets) => {
          logger.info("Generated assets:");
          for (const assetName of Object.keys(assets)) {
            logger.info(assetName);
          }
        },
      );
    });
  }
}

export default AssetLoggerPlugin;

يدخل هذا plugin إلى hook المسمى emit في webpack compiler ويطبع أسماء كل assets المولدة في console. يوضح كيف يمكن لـ plugins التفاعل مع عملية compilation باستخدام webpack hooks.

مثلًا، عند تشغيل webpack build، قد يبدو الناتج بهذا الشكل:

Generated assets:
main.js
vendor.js
styles.css
Edit this page·

1 Contributor

RlxChap2