سبق لي أن تكلمت عن الدوال السهمية في جافا سكريبت وذلك في مقال سابق وقديم على مدونة توتومينا. حينذات قدمت الميزة على أنها من المزايا الجديدة في إصدار ES2015 من لغة البرمجة JavaScript، وكذلك حاولت توضيح عدد من المميزات والإختلافات التي تتميز بها تلك الدوال عن غيرها من الدوال العادية التي تنشَأ عن طريق الكلمة function
.
يتم إنشاء الدوال العادية والقديمة في جافاسكريبت باستخدام طريقتين:
- Function declaration
- Function expression
function doSomething() {
console.log("hello world! I am from a function declaration :)");
}
const doSomething = function () {
console.log("hello world! I am from a function expression :)");
};
أما الدوال السهمية كما هو معروف فيتم إنشاؤها بهذه الطريقة:
const doSomething = () => {
console.log("hello world! I am from an arrow function :)");
};
كل هذه الطرق تستخدم لإنشاء الدوال في جافاسكريبت، ولكن لا يعني هذا أن الدوال السهمية تشتغل وتعمل 100% بنفس طريقة عمل الدوال الإعتيادية. على العكس من ذلك، هناك العديد من الإختلافات بينهما وقد لا تواجهها أو تلقي لها بالاً وأنت تكتب سطورك البرمجية الأولى مع Arrow functions. ولكن مع تقدمك وتعمقك أكثر، ومع مواجهة حالات معينة في عملك ستكتشف أن دالتك السهمية قد تصرفت على نحو غير متوقع منها، وحينذاك ستبدأ ربما في حك فروة رأسك وتسأل نفسك عن السبب.
هذه التدوينة جاء لتجيب على تلك التساؤلات، وتفتح لك الطريق نحو فهم أعمق وأفضل للدوال السهمية.
1. الإختلاف الأول: قيمة this
أو Execution context
من المعروف لكل مطور جافا سكريبت أن قيمة الكائن this
يتحدد لدى الدوال العادية (Regular functions) بناءً على عدد من المعطيات وحسب الكيفية التي تم بها استدعاء الدالة.
فعلى سبيل المثال، عندما نقوم بإنشاء دالة كوظيفة داخل كائن جافاسكريبت معين فإن this
يشير إلى ذلك الكائن الذي يمثل سياق التنفيذ (Execution context) لتلك الدالة أو الوظيفة.
const user = {
login: function () {
console.log(this);
},
};
user.login(); // user
أما عندما نستدعي الدالة في النطاق العام (Global scope) للبرنامج، أي تحت الكائن window
مباشرة، فإن قيمة this
داخل الدالة تشير إلى الكائن الأب window
👇
function login() {
console.log(this);
}
login(); // window
أو تصبح undefined
في حالة الوضع الصارم (Strict mode) 👇
function login() {
"use strict";
console.log(this);
}
login(); // undefined
أما في الحالة التي تستخدم فيها الدالة كَ constructor فإن قيمة this
تشير إلى الكائن الجديد الذي تم إنشاؤه بواسطة new
👇
function User() {
console.log(this);
}
const user = new User(); // user -> instance of User
ويمكن كذلك التحكم في سياق التنفيذ (Execution context) للدوال العادية في جافاسكريبت عن طريق استدعائها بواسطة call
، apply
أو حتى bind
.
هذه الوظائف تقبل كمعامل أول الكائن المراد تنفيذ تلك الدالة في سياقه، وعندئد يصبح كل this
داخل الدالة عائدا عليه.
function login() {
console.log(this.username);
}
const user1 = { username: "ahmed" };
const user2 = { username: "fatima" };
login.call(user1); // ahmed
login.call(user2); // fatima
😄 الموضوع مختلف في الدوال السهمي
كل المميزات أعلاه للدوال العادية فيما يخص تحديد قيمة this
ونطاق التنفيذ عليك نسيانها عند استخدام الدوال السهمية 👊
هذه الأخيرة لا تهتم أبدا بتحديد ماهية this
أو في أي سياق تنفيذ هي موجودة من تلقاء نفسها أو بناءً على كيفية استدعائها، وإنما بناءً على مكان تواجدها في الكود (Lexically)، بنفس الطريقة التي تأخذ فيها المتغيرات الإعتيادية قيمها. أي بالإعتماد على تحليل Lexical Scope.
- إذا كان هذا المصطلح جديدا عليك وتريد معرفة المزيد حوله فهذا المقال من أجلك 👈 شرح مفهوم Scope في جافا سكريبت
فإذا كان Lexical scope الذي قمنا بإنشاء تلك الدالة فيه هو Global scope (أي window
في حالة المتصفح) فإن this
يكون هو window
بغض النظر عن أين وكيف استدعينا الدالة. وإذا تم إنشاؤها داخل دالة أخرى فإنها ستأخذ قيمة this
التي تفرضها عليها تلك الدالة الأم.
لا يهم إذا استدعيت الدالة بواسطة call
أو apply
، ال arrow function تتجاهل كل ذلك ولا تعترف إلا ب Lexical scope.
لا يمكن بعد ذلك للدالة السهمة أن تغير من القيمة المعطاة للكائن this
من تلقاء نفسها إلا أن يأتي التغيير من السلطات العليا نفسها (Lexical scope) 👮
const notify = () => {
console.log(this);
};
function login() {
console.log(this.username);
notify(); // window
}
const user = { username: "ahmed" };
login.call(user); // ahmed
لا حظ في المثال أعلاه، أنه رغم أنه تم استدعائها داخل الدالة login
حيث قيمة this
تشير إلى الكائن user
إلا أن قيمة this
بداخل الدالة السهمية notify
مازالت تشير إلى window
. والسبب أنها Lexically، أي من حيث تركيبة الكود، ما تزال داخل window
وبالتالي فإن this
بالنسبة لها هو window
وليس user
.
وكما قلت سابقا، فإن هذه العملية تتم بنفس الطريقة التي يتم بها إعطاء قيم للمتغيرات الإعتيادية:
const name = "أحمد";
const notify = () => {
console.log(name);
};
function login() {
const name = "فاطمة";
notify(); // أحمد
}
login();
لسان حال الدالة السهمية يقول: بما أن نطاقي الأب (Parent scope) حيث تم إنشائي أول مرة هو Global scope (window
في المتصفح أو globalThis
في Node.js) فإن قيمة المتغير name
التي أعرفها هي أحمد
التي جاءت من Parent scope، ولا شأن لي في أي مكان تم فيه استدعائي بعد ذلك.
إذا أردنا للدالة السهمية أن تعطي للكائن this
نفس القيمة المعطاة له في الدالة login
فيجب علينا إنشاؤها بداخلها ليصبح نطاقها الأب هو الدالة login
، وبالتالي ترث منها قيمة this
كما أصبح مفهوما لدينا الآن 👇
function login() {
console.log(this.username);
const notify = () => {
console.log(this);
};
notify(); // أحمد
}
const user = { username: "أحمد" };
login.call(user); // أحمد
من هذا كله، أصبح واضحا لدينا ضرورة الحذر عن استخدام هذين النوعين من الدوال في JavaScript، وضرورة معرفة خصوصية ومميزات كل نوع على حدة في هذه النقطة المتعلقة بالكائن this
.
2. الإختلاف الثاني: استخدام الدالة كَ constructor
بما أن الدوال السهمية ليست معنية بإعطاء قيمة للكائن this
بناء على طريقة استدعائها فإن ذلك يعني بأنها غير صالحة وغير مصممة لتلعب دوال ال class أو constructor.
لا يمكن إنشاء كائنات عن طريق new
بواسطة الدوال السهمية كما هو ممكن مع الدوال العادية. إذا حاولت فعل ذلك فإن جافا سكريبت سيعتبر ذلك بمثابة خطأ برمجي.
function GoodUserClass(name) {
this.name = name;
}
const BadUserClass = name => {
this.name = name;
};
const ahmed = new GoodUserClass("أحمد"); // صحيح
const fatima = new BadUserClass("فاطمة"); // Uncaught TypeError: BadUserClass is not a constructor
3. الإختلاف الثالث: الكائن arguments
من المعروف لدى مطوري جافا سكريبت أنه يمكن الوصول إلى المعاملات التي مُرِّرَتْ للدالة العادية عند استدعائها، وذلك عن طريق الكائن السحري arguments
.
لاحظ الدالة العادية في الكود أسفله 👇
function regFunction() {
console.log(arguments);
}
regFunction("param1", "param2"); // [object Arguments] { 0: "param1", 1: "param2"}
هذه الميزة القديمة تعطينا إمكانية استخدام arguments
لجعل الدالة ديناميكية ومرنة أكثر، وذلك عن طريق فعل أشياء محددة بحسبب عدد المعاملات الممررة.
ماذا عن الدوال السهمية ؟
في الدوال السهمية يعمل الكائن arguments
بطريقة مختلفة.
يأخذ هذا الكائن قيمته من خلال Lexical scope، تماما كما رأينا قبل قليل مع الكائن this
.
فإذا أنشأنا الدالة السهمية في Global scope فإن قيمة arguments
ستكون غير معرفة (not defined) لأنه لا وجود لكائن اسمه arguments
في النطاق العام. وعندما نحاول الوصول لمتغير غير معرف في جافا سكريبت فذلك يعتبر بمثابة خطأ برمجي.
const arrFunction = () => {
console.log(arguments);
};
arrFunction("arrParam1", "arrParam2"); // Uncaught ReferenceError: arguments is not defined
أما إذا أنشأنا الدالة داخل دالة أخرى فإن الكائن arguments
الخاص بها سيشير إلى ذات الكائن arguments
الخاص بدالتها الأم حيث هي موجودة.
function regFunction() {
console.log(arguments);
const arrFunction = () => {
console.log(arguments);
};
arrFunction("arrParam1", "arrParam2", "arrParam3"); // [object Arguments] { 0: "regParam1", 1: "regParam2"}
}
regFunction("regParam1", "regParam2"); // [object Arguments] { 0: "regParam1", 1: "regParam2"}
4. الإختلاف الرابع: الإرجاع
كما هو معلوم، نقوم بإرجاع قيمة معينة من الدالة في جافاسكريبت عن طريق الكلمة المفتاحية return
.
// Return number
function sum(a, b) {
return a + b;
}
// Return object
function getUser() {
return {
id: "134o-idka-867986",
username: "tutomena",
};
}
كانت هذه الطريقة الوحيدة لإرجاع شيء ما من دالة في لغة جافا سكريبت. ولكن بعد ظهور الدوال السهمية أصبح بإمكاننا الإرجاع بطرق مختلفة وبدون الحاجة لكلمة return
إذا كانت الدالة تتكون من سطر واحد فقط.
// Return number
const sum = (a, b) => a + b;
// Return object
const getUser = () => ({
id: "134o-idka-867986",
username: "tutomena",
});
الكود أعلاه رغم اختلافه عن الذي قبله من ناحية Syntax إلا أنهما متطابقان من الناحية العملية.
إذا كانت الدالة متعددة الأسطر فيمكن استخدام الكلمة return
للإرجاع.
const getUser = async userId => {
const user = await fetchUser(userId);
return user;
};
كما هو واضح من المثال، لا حاجة للأقواس في حال كانت الدالة تقبل معاملا واحدا فقط (
userId
في المثال).
5. الإختلاف الخامس: الإستخدام كوظائف
عادة تستخدم الدوال الإعتيادية كوظائف سواء في الكلاسات أو الكائنات (Literal Object) لأنها كما رأينا من قبل تكون مرتبطة بذلك الكائن حيث يمكن الوصول إليه من داخل الدالة عن طريق this
.
const counter = {
count: 0,
increment: function () {
this.count++;
console.log(this.count);
},
};
counter.increment(); // 1
class Counter {
count = 0;
increment() {
this.count++;
console.log(this.count);
}
}
const counter = new Counter();
counter.increment(); // 1
ولكن ماذا لو استخدمنا تلك الوظيفة في مكان تكون فيه مربوطة بسياق (context) مختلف ؟ مثلا في حدث (Event) أو دالة علوية مثل setTimeout
؟
لاحظ الكود أسفله 👇
class Counter {
count = 0;
increment() {
this.count++;
console.log(this.count);
}
}
const counter = new Counter();
setTimeout(counter.increment, 1000); // NaN
بتشغيل هذا الكود سنلاحظ بأنه سيطبع القيمة NaN
، فلماذا ذلك ؟
السبب هو أن الدالة التي يتم تمريرها ل setTimeout
يتم ربطها (bound) بسياق مختلف (window
) عن السياق الذي كانت مرتبطة به في البداية وهو الكائن counter
. وبالتالي فإن قيمة this.count
تكون undefined
(this
أصبح هو window
)، ما يفسر محصلة العملية undefined + 1
والتي أعطتنا NaN
.
الحل
لإجبار الوظيفة counter.increment
على أن تظل وفية للكائن الذي تنتمي له، يجب علينا أن نطلب منها ذلك بشكل صريح وذلك عن طريق bind
.
class Counter {
count = 0;
increment() {
this.count++;
console.log(this.count);
}
}
const counter = new Counter();
setTimeout(counter.increment.bind(counter), 1000); // 1
وإذا كنت من مستخدمي ومطوري مكتبة React فبنسبة كبيرة سبق لك استخدام هذا الحل مرارا وتكرارا عندما كنت تحاول الوصول إلى this.setState
أو this.state
بداخل Event handlers في Class Components.
import React from "react";
export class App extends React.Component {
constructor(props) {
super(props);
}
handleClick(event) {
console.log(this); // 'this' is undefined 👎
}
render() {
return (
<button type="button" onClick={this.handleClick}>
Click Me
</button>
);
}
}
الحل
import React from "react";
export class App extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(event) {
console.log(this); // `this` is `App` component 👍
}
render() {
return (
<button type="button" onClick={this.handleClick}>
Click Me
</button>
);
}
}
ماذا عن الدوال السهمية ؟
لا وجود لهذه المشكلة في الدوال السهمية، فهي كما رأينا سابقا تظل وفية للسياق الذي أُنشأت فيه (لا تنسى موضوع Lexical scope) بغض النظر عن كيفية استدعائها.
وهذا نفسه السبب في أن الدوال السهمية غير ملائمة للإستخدام كوظائف (Methods).
فلو أنشأنا كائنا object
يضم وظيفة على شكل دالة سهمية فإن الكائن this
سيشير إلى window
إذا اعتبرنا أن الكائن object
تم إنشاؤه في Global scope.
هذا يعني بأن الدالة السهمية عديمة الفائدة كوظيفة في هذه الحالة.
const object = {
someMethod: () => {
console.log(this);
},
};
object.someMethod(); // Window
أما داخل الكلاسات فيمكن استخدام الدالة السهمية كوظيفة على شكل Class field، وحينها يظل this
داخلها يؤشر دائما على الكلاس أو instance نفسها.
class Counter {
count = 0;
increment = () => {
this.count++;
console.log(this.count);
};
}
const counter = new Counter();
counter.increment(); // 1
setTimeout(counter.increment, 1000); // 2
هناك عيب وحيد لاستخدام الوظائف كَ Class field، وهي أن كل نسخة (instance) جديدة من الكلاس تنشئ دالة increment
جديدة خاصة بها. على عكس الوظائف (Class methods) التي يتم إنشاؤها داخل الكائن prototype
وبالتالي تصبح مشتركة بين جميع النسخ التي يتم إنشاؤها من هذا الكلاس.
class Counter {
count = 0;
increment = () => {
this.count++;
console.log(this.count);
};
doSomething() {
// do something
}
}
const counter1 = new Counter();
const counter2 = new Counter();
console.log(counter1.increment === counter2.increment); // false
console.log(counter1.doSomething === counter2.doSomething); // true
أقول هذا الكلام لأُبين لك أن الدوال السهمية لا تصلح للإستخدام كوظائف بصفة عامة، حتى وإن كان استخدامها داخل الكلاس على شكل Class Field يفي بالغرض إلا أنه حل لا ينصح به كثيرا خاصة إذا كان موضوع ال Performance يهمك كثيرا.
خاتمة
لا شك بأن الدوال السهمية ميزة عظيمة في لغة البرمجة جافا سكريبت، ولكنها مع ذلك لا يجب أن تكون بديلا فوريا للدوال التقليدية خاصة إذا لم نكن على بينة ومعرفة كاملتين بالفوارق الأساسية بينهما.
بعد كل ما تحدثنا عنه، من المفروض الآن أننا أصبحنا قادرين على التمييز بين الدوال السهمية والدوال التقليدية القديمة، وبتنا قادرين على معرفة متى يمكننا الإستعانة بأحد الخيارين على حساب الآخر.