كتابة Loader

الـ loader هو node module يصدّر دالة. تُستدعى هذه الدالة عندما يحتاج resource إلى تحويل بواسطة هذا loader. تحصل الدالة على إمكانية الوصول إلى Loader API من خلال سياق this الذي يمرره webpack لها.

الإعداد

قبل أن ندخل في أنواع loaders المختلفة وطريقة استخدامها والأمثلة عليها، لننظر إلى ثلاث طرق يمكنك بها تطوير loader واختباره محليًا.

لاختبار loader واحد، يمكنك استخدام path مع resolve للإشارة إلى ملف محلي داخل كائن rule:

webpack.config.js

import path from "node:path";

export default {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.resolve("path/to/loader.js"),
            options: {
              /* ... */
            },
          },
        ],
      },
    ],
  },
};

لاختبار أكثر من loader، يمكنك استخدام إعداد resolveLoader.modules لتغيير الأماكن التي يبحث فيها webpack عن loaders. مثلًا، إذا كان لديك مجلد محلي باسم /loaders داخل مشروعك:

webpack.config.js

import path from "node:path";

const __dirname = import.meta.dirname;

export default {
  // ...
  resolveLoader: {
    modules: ["node_modules", path.resolve(__dirname, "loaders")],
  },
};

وبالمناسبة، إذا أنشأت مستودعًا وحزمة منفصلين للـ loader، يمكنك استخدام npm link لربطه بالمشروع الذي تريد اختباره فيه.

استخدام بسيط

عند تطبيق loader واحد على resource، يتم استدعاء loader مع parameter واحد فقط: نص يحتوي على محتوى ملف resource.

يمكن للـ loaders المتزامنة أن تستخدم return لإرجاع قيمة واحدة تمثل module بعد التحويل. وفي الحالات الأكثر تعقيدًا، يمكن للـ loader إرجاع أي عدد من القيم باستخدام الدالة this.callback(err, values...). يتم تمرير الأخطاء إلى this.callback أو رميها مباشرة من loader متزامن.

من المتوقع أن يعيد loader قيمة واحدة أو قيمتين. القيمة الأولى هي كود JavaScript الناتج كنص أو buffer. والقيمة الثانية اختيارية، وهي SourceMap ككائن JavaScript.

استخدام معقد

عند ربط عدة loaders كسلسلة، من المهم تذكر أنها تُنفّذ بترتيب عكسي: من اليمين إلى اليسار، أو من الأسفل إلى الأعلى بحسب صيغة array.

  • آخر loader، وهو الذي يُستدعى أولًا، يستقبل محتوى resource الخام.
  • أول loader، وهو الذي يُستدعى أخيرًا، يجب أن يرجع JavaScript وsource map اختياريًا.
  • loaders الواقعة بينهما تُنفّذ باستخدام نتائج loader السابق في السلسلة.

في المثال التالي، سيستقبل foo-loader محتوى resource الخام، ثم يستقبل bar-loader ناتج foo-loader ويرجع module النهائي بعد التحويل وsource map إذا لزم الأمر.

webpack.config.js

export default {
  // ...
  module: {
    rules: [
      {
        test: /\.js/,
        use: ["bar-loader", "foo-loader"],
      },
    ],
  },
};

Pitching loaders

تعمل loaders عادةً من اليمين إلى اليسار. ويمكن لـ loader أيضًا تصدير دالة pitch تعمل من اليسار إلى اليمين قبل أن تبدأ المرحلة العادية. هذا يسمح للـ loader بتمرير بيانات إلى مرحلته العادية، أو باختصار بقية سلسلة loaders.

// my-loader.js
export default function (source) {
  // المرحلة العادية: تعمل من اليمين إلى اليسار
  const prefix = this.data.value ?? "";
  return `${prefix}\n${source}`;
}

export function pitch(remainingRequest, precedingRequest, data) {
  // مرحلة pitch: تعمل من اليسار إلى اليمين قبل loaders العادية
  data.value = "/* processed by my-loader */";
}

اختصار سلسلة loader

إذا أرجعت دالة pitch قيمة، يتجاوز webpack بقية loaders الموجودة على اليمين وينعكس الاتجاه فورًا. يفيد هذا عندما تريد توليد كود module مبكرًا وتجاوز المرحلة العادية.

export function pitch(remainingRequest) {
  return `
import style from ${JSON.stringify(`!!${remainingRequest}`)};
const el = document.createElement("style");
el.textContent = style;
document.head.appendChild(el);
  `;
}

إرشادات

يجب اتباع الإرشادات التالية عند كتابة loader. رُتبت حسب الأهمية، وبعضها لا ينطبق إلا في سيناريوهات معينة. اقرأ الأقسام التفصيلية التالية لمزيد من المعلومات.

  • اجعله بسيطًا.
  • استفد من chaining.
  • أخرج ناتجًا modular.
  • تأكد من أنه stateless.
  • استخدم loader utilities.
  • علّم loader dependencies.
  • حل module dependencies.
  • استخرج الكود المشترك.
  • تجنب المسارات المطلقة.
  • استخدم peer dependencies.

البساطة

ينبغي أن يؤدي loader مهمة واحدة فقط. هذا لا يجعل صيانته أسهل فحسب، بل يسمح أيضًا بربطه مع loaders أخرى لاستخدامه في سيناريوهات أكثر.

Chaining

استفد من أن loaders يمكن ربطها معًا. بدل كتابة loader واحد يتعامل مع خمس مهام، اكتب خمسة loaders أبسط تقسم هذا العمل. عزل المهام يجعل كل loader بسيطًا، وقد يسمح باستخدامه في أشياء لم تكن تفكر فيها أصلًا.

خذ مثال تحويل ملف template باستخدام بيانات محددة عبر خيارات loader أو query parameters. يمكن كتابته كـ loader واحد يترجم template من المصدر، ثم ينفذه، ثم يرجع module يصدّر نص HTML. لكن باتباع الإرشادات، يوجد apply-loader يمكن ربطه مع loaders مفتوحة المصدر:

  • pug-loader: يحوّل template إلى module يصدّر دالة.
  • apply-loader: ينفّذ الدالة باستخدام خيارات loader ويرجع HTML خام.
  • html-loader: يستقبل HTML ويخرج JavaScript module صالحًا.

Modular

حافظ على ناتج modular. يجب أن تحترم modules المولدة بواسطة loader مبادئ التصميم نفسها التي تحترمها modules العادية.

Stateless

تأكد من أن loader لا يحتفظ بحالة بين تحويلات modules. يجب أن يكون كل تشغيل مستقلًا عن modules الأخرى التي تم تجميعها، وكذلك عن compilations السابقة للـ module نفسه.

Loader Utilities

استفد من حزمة loader-utils، فهي توفر أدوات مفيدة متنوعة. وبجانب loader-utils، ينبغي استخدام حزمة schema-utils للتحقق المنتظم من خيارات loader بناءً على JSON Schema. هذا مثال مختصر يستخدم الحزمتين:

loader.js

import { urlToRequest } from "loader-utils";
import { validate } from "schema-utils";

const schema = {
  type: "object",
  properties: {
    test: {
      type: "string",
    },
  },
};

export default function (source) {
  const options = this.getOptions();

  validate(schema, options, {
    name: "Example Loader",
    baseDataPath: "options",
  });

  console.log("The request path", urlToRequest(this.resourcePath));

  // طبّق بعض التحويلات على source...

  return `export default ${JSON.stringify(source)}`;
}

مشاركة البيانات

في webpack، يمكن ربط loaders معًا ومشاركة البيانات مع loaders التالية في السلسلة. لتحقيق ذلك، يمكنك تمرير البيانات مع المحتوى، أي source code، باستخدام الدالة this.callback داخل raw loaders. في الدالة المصدّرة افتراضيًا من raw loader، يمكنك تمرير البيانات باستخدام argument الرابع من this.callback.

export default function (source) {
  const options = getOptions(this);
  // مرر البيانات باستخدام argument الرابع من this.callback
  this.callback(null, `export default ${JSON.stringify(source)}`, null, {
    some: data,
  });
}

في المثال السابق، تُستخدم الخاصية some في argument الرابع من this.callback لتمرير البيانات إلى loader التالي في السلسلة.

Loader Dependencies

إذا كان loader يستخدم موارد خارجية، مثل القراءة من نظام الملفات، فيجب أن يوضح ذلك. تُستخدم هذه المعلومة لإبطال cacheable loaders وإعادة compilation في watch mode. هذا مثال مختصر على فعل ذلك باستخدام الدالة addDependency:

loader.js

import path from "node:path";

export default function (source) {
  const callback = this.async();
  const headerPath = path.resolve("header.js");

  this.addDependency(headerPath);

  fs.readFile(headerPath, "utf8", (err, header) => {
    if (err) return callback(err);
    callback(null, `${header}\n${source}`);
  });
}

Module Dependencies

بحسب نوع module، قد توجد طريقة مختلفة لتحديد dependencies. في CSS مثلًا، تُستخدم عبارات @import وurl(...). يجب حل هذه dependencies بواسطة نظام modules.

يمكن فعل ذلك بإحدى طريقتين:

  • تحويلها إلى عبارات require.
  • استخدام الدالة this.resolve لحل المسار.

يُعد css-loader مثالًا جيدًا على الطريقة الأولى. فهو يحوّل dependencies إلى requires، عبر استبدال عبارات @import بـ require إلى stylesheet الأخرى، واستبدال url(...) بـ require إلى الملف المشار إليه.

في حالة less-loader، لا يستطيع تحويل كل @import إلى require لأن كل ملفات .less يجب أن تُجمّع في مرور واحد لتتبع variables وmixins. لذلك يوسّع less-loader compiler الخاص بـ less بمنطق مخصص لحل المسارات. ثم يستفيد من الطريقة الثانية، this.resolve، لحل dependency عبر webpack.

الكود المشترك

  • تجنب توليد الكود المشترك في كل module يعالجه loader. بدلًا من ذلك، أنشئ ملف runtime داخل loader، ثم استخدم import أو require له كـ module مشترك:

src/loader-runtime.js

import { someOtherModule } from "./some-other-module.js";

export default function runtime(params) {
  const x = params.y * 2;

  return someOtherModule(params, x);
}

src/loader.js

import runtime from "./loader-runtime.js";

export default function loader(source) {
  // منطق loader المخصص

  return `${runtime({
    source,
    y: Math.random(),
  })}`;
}

المسارات المطلقة

لا تُدخل مسارات مطلقة داخل كود module، لأنها تكسر hashing عندما يتم نقل جذر المشروع. يمكنك استخدام الكود التالي لتحويل المسارات المطلقة إلى مسارات نسبية.

// `loaderContext` هو نفسه `this` داخل دالة loader
JSON.stringify(
  loaderContext.utils.contextify(
    loaderContext.context || loaderContext.rootContext,
    request,
  ),
);

Peer Dependencies

إذا كان loader الذي تعمل عليه مجرد wrapper بسيط حول حزمة أخرى، فيجب إضافة هذه الحزمة كـ peerDependency. هذا الأسلوب يسمح لمطور التطبيق بتحديد الإصدار الدقيق داخل package.json إذا أراد.

مثلًا، يحدد sass-loader حزمة node-sass كـ peer dependency بهذا الشكل:

{
  "peerDependencies": {
    "node-sass": "^4.0.0"
  }
}

الاختبار

كتبت loader واتبعت الإرشادات السابقة وجهزته للتشغيل محليًا. ما الخطوة التالية؟ سنمر على مثال unit testing للتأكد من أن loader يعمل كما نتوقع. سنستخدم إطار Jest لهذا الغرض. وسنثبت أيضًا babel-jest وبعض presets التي تسمح لنا باستخدام import / export وasync / await. لنبدأ بتثبيت هذه الحزم وحفظها كـ devDependencies:

npm install --save-dev jest babel-jest @babel/core @babel/preset-env

babel.config.js

export default {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current",
        },
      },
    ],
  ],
};

سيعالج loader ملفات .txt ويستبدل أي ظهور لـ [name] بقيمة خيار name الممررة إلى loader. ثم سيخرج JavaScript module صالحًا يحتوي على النص كـ default export:

src/loader.js

export default function loader(source) {
  const options = this.getOptions();

  source = source.replaceAll(/\[name\]/, options.name);

  return `export default ${JSON.stringify(source)}`;
}

سنستخدم هذا loader لمعالجة الملف التالي:

test/example.txt

Hey [name]!

انتبه جيدًا للخطوة التالية؛ سنستخدم Node.js API وmemfs لتشغيل webpack. هذا يسمح لنا بتجنب كتابة output على القرص، ويمنحنا وصولًا إلى بيانات stats التي يمكننا استخدامها لجلب module بعد التحويل:

npm install --save-dev webpack memfs

test/compiler.js

import path from "node:path";
import { Volume, createFsFromVolume } from "memfs";
import webpack from "webpack";

const __dirname = import.meta.dirname;

export default (fixture, options = {}) => {
  const compiler = webpack({
    context: __dirname,
    entry: `./${fixture}`,
    output: {
      path: path.resolve(__dirname),
      filename: "bundle.js",
    },
    module: {
      rules: [
        {
          test: /\.txt$/,
          use: {
            loader: path.resolve(__dirname, "../src/loader.js"),
            options,
          },
        },
      ],
    },
  });

  compiler.outputFileSystem = createFsFromVolume(new Volume());
  compiler.outputFileSystem.join = path.join.bind(path);

  return new Promise((resolve, reject) => {
    compiler.run((err, stats) => {
      if (err) reject(err);
      if (stats.hasErrors()) reject(stats.toJson().errors);

      resolve(stats);
    });
  });
};

والآن، أخيرًا، يمكننا كتابة الاختبار وإضافة npm script لتشغيله:

test/loader.test.js

/**
 * @jest-environment node
 */
import compiler from "./compiler.js";

test("Inserts name and outputs JavaScript", async () => {
  const stats = await compiler("example.txt", { name: "Alice" });
  const output = stats.toJson({ source: true }).modules[0].source;

  expect(output).toBe('export default "Hey Alice!\\n"');
});

package.json

{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "testEnvironment": "node"
  }
}

بعد تجهيز كل شيء، يمكننا تشغيل الاختبار ورؤية ما إذا كان loader الجديد ينجح:

 PASS  test/loader.test.js
  ✓ Inserts name and outputs JavaScript (229ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.853s, estimated 2s
Ran all test suites.

نجح الاختبار. في هذه المرحلة يجب أن تكون جاهزًا لبدء تطوير loaders الخاصة بك واختبارها ونشرها. ونأمل أن تشارك ما تبنيه مع بقية المجتمع.

·تعديل هذه الصفحة
السابق ›
دليل الكتابة
‹ التالي
كتابة Plugin

1 مساهم

RlxChap2