ساخت کتابخانه مدیریت حالت جاوا اسکریپت — از صفر تا صد

۱۴۱ بازدید
آخرین به‌روزرسانی: ۰۸ شهریور ۱۴۰۲
زمان مطالعه: ۷ دقیقه
ساخت کتابخانه مدیریت حالت جاوا اسکریپت — از صفر تا صد

اگر تنها یک موضوع باشد که در همه مباحث مربوط به توسعه با زبان جاوا اسکریپت مشترک است، آن «مدیریت حالت» (State Managment) است. راه‌حل‌های آماده زیادی از قبیل Redux ،Mobx و Vuex برای مدیریت حالت وجود دارند. مستندات نسبتاً زیادی نیز برای یادگیری شیوه استفاده از آن‌ها ارائه شده‌اند و به این ترتیب انتخاب یکی از آن‌ها در پروژه‌ها آسان گشته است. این مقدار برای نیازهای اغلب افراد کفایت می‌کند، اما ممکن است برخی افراد کنجکاو باشند بدانند این کتابخانه‌های جادویی در پشت پرده چگونه کار می‌کنند. بدین منظور ما در این راهنما خودمان یک کتابخانه مدیریت حالت جاوا اسکریپت می‌نویسیم تا در پروژه‌های کوچکمان از آن بهره بگیریم و به این ترتیب به صورت عملی با این موضوع آشنا شویم.

مدیریت حالت اساساً یک مدیریت مسیر دوطرفه است، یعنی باید اجازه بدهید اپلیکیشن شما حالت را به‌روزرسانی کند و به بخش‌های دیگر اپلیکیشن اطلاع دهد که حالت تغییر یافته است تا آن‌ها بتوانند به این تغییر واکنش نشان داده و هر کاری را که لازم است انجام دهند.

بخش نخست آسان است. تنها کاری که باید بکنیم نوشتن کلاسی است که یک حالت درونی (یک شیء) دارد و متدی نیز وجود دارد که در هنگام فراخوانی، این حالت را بر اساس حالت جدید دریافتی از سوی متد به‌روزرسانی می‌کند.

به این منظور از الگوی getter و setter استفاده می‌کنیم که حالت را بازیابی می‌کند و از تغییر یافتن مستقیم حالت ممانعت به عمل می‌آورد (مقدار false بازگشت می‌دهد).

برای تغییر دادن حالت به روش «تغییرناپذیر» (immutable) متد setState طراحی شده است و کاری می‌کنیم که حالت اولیه با استفاده از متد سازنده تعیین شود.

در متد get حالت، یک شیء جدید بازگشت می‌دهیم که یک کلون از حالت درونی است و بنابراین مصرف‌کننده ارجاعی به حالت درونی به دست می‌آورد.

قابلیت مهم دیگر که استفاده می‌کنیم مشخصه‌های خصوصی کلاس‌ها هستند که در استاندارد ECMA پیاده‌سازی می‌شوند. اگر نمی‌دانید در مورد چه چیزی صحبت می‌کنیم این مستندات (+) را بخوانید و سپس به ادامه مطالعه این راهنما بپردازید.

این قابلیت در مرورگرهای کنونی موجود است و بنابراین برای استفاده از آن باید افزونه babel/plugin-proposal-class-properties@ را به صورت زیر در فایل babelrc. خود نصب و تعیین کنید و سپس از babel برای کامپایل کردن کتابخانه بهره بگیرید.

1{
2  "presets": ["@babel/preset-env"],
3  "plugins": [["@babel/plugin-proposal-class-properties", { "loose": true }]]
4}

ما دو تابع کمکی نیز اضافه می‌کنیم که یکی برای کلون کردن یک شیء و دیگری برای بررسی این نکته است که آیا مقدار مورد نظر به یک شیء ارسال شده است یا نه. پس از همه این توضیحات، کد اولیه به صورت زیر است:

1const clone = o => JSON.parse(JSON.stringify(o));
2
3const isObject = val => val != null && typeof val === 'object' && Array.isArray(val) === false;
4
5class Store {
6  
7  #internalState
8  
9  constructor(initialState = {}) {
10    if (!isObject(initialState)) throw new Error('initial state must be a object');
11    this.#internalState = initialState;
12  }
13
14  get state() {
15    return clone(this.#internalState);
16  }
17
18  set state(value) {
19    return false;
20  }
21    
22  setState(value) {
23    if (!isObject(value)) throw new Error('value must be a object');
24    const currentState = clone(this.#internalState);
25    const nextState = Object.assign(clone(currentState), clone(value))
26    this.#internalState = nextState
27    return nextState;
28  }
29  
30}

با به پایان رسیدن این بخش، اکنون به بخش دوم می‌پردازیم. برای اجرای بخش دوم باید به فکر روشی باشیم که به اجزای دیگر اپلیکیشن اطلاع دهیم که حالت تغییر یافته است. به این منظور از الگوی شناخته‌شده‌ای در دنیای برنامه‌نویسی به نام pubsub یا «ناشر-مشترک» (publisher-subscriber) استفاده می‌کنیم.

بر اساس اطلاعات ویکی‌پدیا، الگوی publisher-subscriber یک الگوی پیام‌رسانی است که در آن فرستنده‌های پیام، که ناشر نامیده می‌شوند، پیام‌ها را به صورت مستقیم به گیرنده‌های خاصی که اینجا مشترک نامیده می‌شوند، ارسال نمی‌کنند، بلکه پیام‌های انتشاریافته را در دسته‌هایی بدون اطلاع از مشترکانشان تقسیم‌بندی می‌کنند. به طور مشابه مشترکان به یک یا چند دسته ابزار علاقه می‌کنند و تنها آن دسته پیام‌های مورد علاقه خود را دریافت می‌کنند و نمی‌دانند که ناشران پیام‌ها چه کسی هستند.

بنابراین مدیریت حالت خود را تغییر می‌دهیم و یک کلاس Pubsub با متدهای انتشار و اشتراک اضافه می‌کنیم. این کلاس از سوی متد setState کلاس Store برای مدیریت اعلان‌های مشترکان مورد استفاده قرار می‌گیرد. بدین ترتیب هنگامی که می‌خواهیم برخی کدها را در زمان تغییر یافتن حالت تغییر دهیم، متد اشتراک را فراخوانی کرده و تابعی که باید فراخوانی شود را ارسال می‌کنیم. این تابع callback حالت بعدی را دریافت خواهد کرد و بدین ترتیب به آن واکنش نشان می‌دهد. اینک کد به صورت زیر درآمده است:

1const clone = o => JSON.parse(JSON.stringify(o));
2
3const isObject = val => val != null && typeof val === 'object' && Array.isArray(val) === false;
4
5class PubSub {
6  
7  #callbackList
8  
9  constructor() {
10    this.#callbackList = [];
11  }
12
13  publish(state) {
14    if (!isObject(state)) throw new Error('state should be and object');
15    this.callbackList.forEach(callback => {
16      callback(state);
17    });
18  }
19
20  subscribe(callback) {
21    if (typeof callback !== 'function')
22      throw new Error('callback should be a function');
23    this.#callbackList = [
24      ...this.#callbackList,
25      callback
26    ];
27    return true;
28  }
29}
30
31class Store {
32  
33  #internalState
34  #pubsub
35  
36  constructor(initialState = {}) {
37    if (!isObject(initialState)) throw new Error('initial state must be a object');
38    this.#internalState = clone(initialState);
39    this.#pubsub = new Pubsub()
40  }
41
42  get state() {
43    return clone(this.#internalState);
44  }
45
46  set state(value) {
47    return false;
48  }
49    
50  setState(value) {
51    if (!isObject(value)) throw new Error('value must be a object');
52    const currentState = clone(this.#internalState);
53    const nextState = Object.assign(clone(currentState), clone(value))
54    this.#pubsub.publish(nextState)
55    this.#internalState = nextState
56    return nextState;
57  }
58    
59  subscribe(callback) {
60    return this.#pubsub.subscribe(callback)
61  }
62  
63}
64}

اکنون می‌توانیم کتابخانه مدیریت حالت خود را با کد زیر تست کنیم:

1const store = new Store();
2
3const firstCallback = state => { console.log('first callback', state) };
4const secondCallback = state => { console.log('second callback', state) };
5
6store.subscribe(firstCallback);
7store.subscribe(secondCallback);
8
9store.setState({a : 1});
10store.setState({b : 1});

با اجرای این کد، می‌بینیم که همه چیز به درستی کار می‌کند و هر زمان که حالت تغییر پیدا کند، هر دو تابع callback فراخوانی شده و حالت در کنسول لاگ می‌شود.

این وضعیت برای اپلیکیشن‌های کوچک مناسب به نظر می‌رسد، اما در اغلب موارد ما تنها می‌خواهم تابع callback را هنگامی اجرا کنیم که اجزای خاصی از حالت تغییر پیدا می‌کنند، چون در غیر این صورت اجرای کد غیرضروری اتفاق می‌افتد و منجر به مشکلات عملکردی می‌شود. الگوی pubsub این وضعیت را با استفاده از کانال‌های نام دار پیاده‌سازی می‌کند. بنابراین هر زمان که می‌خواهید مشترک شوید، باید کانالی که به آن علاقه‌مند هستید را اطلاع دهید. همین اتفاق در مورد متد انتشار نیز وجود دارد و باید کانالی که می‌خواهید اطلاعات در آن انتشار یابند را اطلاع دهید تا مشترکان مربوطه اطلاع پیدا کنند.

این نوع از راه‌حل، دست‌کم برای ما بهینه به نظر نمی‌رسد. بنابراین تلاش خواهیم کرد که نوع دیگری از پیاده‌سازی داشته باشیم که در آن مشترکان باید اطلاع دهند که به کدام بخش از حالت علاقه‌مند هستند.

از میان راه‌حل‌های موجود، یک مورد وجود دارد که این نوع راه‌حل را به ساده‌ترین روش پیاده‌سازی کرده است و IMHO نام دارد که روش اتصال React به Redux است. همچنان که در مستندات آن (+) آمده است، متد اتصال، حالت به‌روزرسانی شده را دریافت کرده و یک شیء بازگشت می‌دهد که بخشی از حالت است. بدین ترتیب متد اتصال می‌داند که آیا باید props کامپوننت را به‌روزرسانی کند یا نه.

ما می‌توانیم همین کار را در کتابخانه کوچک مدیریت حالت خود اجرا کنیم. هر زمان که فردی می‌خواهد در تغییرات حالت مشترک شود، تابع callback و تابع دیگری مشابه mapStateToProps را ارسال می‌کند. این تابع دوم را از این پس تابع پیکربندی می‌نامیم.

با این فرض متد اشتراک خود را تغییر می‌دهیم تا این دو تابع را دریافت کرده و آن‌ها را در یک شیء ({ callback, config}) در آرایه callbackList ذخیره کند. در متد انتشار حالت جاری و بعدی را در کلاس Stroe دریافت می‌کنیم. سپس درون حلقه که مسئول فراخوانی تابع‌های callback است، هر دو حالت را روی تابع پیکربندی هر مشترک اعمال می‌کنیم و عدم برابر بودن آن‌ها را مورد بررسی قرار می‌دهیم. زمانی که از این مسئله مطمئن شدیم، در ادامه تابع callback را فراخوانی کرده و نتیجه حالت بعدی را که روی تابع پیکربندی اعمال‌شده ارسال می‌کنیم. وقتی همه چیز را این‌گونه توضیح می‌دهیم ممکن است کمی دشوار به نظر برسد، اما به محض این کد آن را بنویسیم، می‌بینید که بسیار آسان است.

همچنین یک متد کمکی دیگر به نام isEqual پیاده‌سازی می‌کنیم که مسئول مقایسه شی‌ءها است. این متد بسیار ساده است و صرفاً از متد JSON.stringify برای تعیین این که آیا دو شیء مفروض برابر هستند یا نه استفاده می‌شود. بدین ترتیب کد نهایی به صورت زیر خواهد بود:

1const clone = o => JSON.parse(JSON.stringify(o));
2
3const isObject = val => val != null && typeof val === 'object' && Array.isArray(val) === false;
4
5const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b)
6
7class PubSub {
8  
9  #callbackList
10  
11  constructor() {
12    this.#callbackList = [];
13  }
14
15  publish(currentState, nextState) {
16    if (!isObject(currentState)) throw new Error('currentState should be and object');
17    if (!isObject(nextState)) throw new Error('nextState should be and object');
18    this.callbackList.forEach(item => {
19      const currentValue = item.config(currentState);
20      const nextValue = item.config(nextState);
21      if (!isEqual(currentValue, nextValue)) {
22        item.callback(nextValue);
23      }
24    });
25  }
26
27  subscribe(callback, config) {
28    if (typeof callback !== 'function')
29      throw new Error('callback should be a function');
30    if (typeof config !== 'function')
31      throw new Error('config should be a function');
32    this.#callbackList = [
33      ...this.#callbackList,
34      { callback, config } 
35    ];
36    return true;
37  }
38}
39
40class Store {
41  
42  #internalState
43  #pubsub
44  
45  constructor(initialState = {}) {
46    if (!isObject(initialState)) throw new Error('initial state must be a object');
47    this.#internalState = clone(initialState);
48    this.#pubsub = new Pubsub()
49  }
50
51  get state() {
52    return clone(this.#internalState);
53  }
54
55  set state(value) {
56    return false;
57  }
58    
59  setState(value) {
60    if (!isObject(value)) throw new Error('value must be a object');
61    const currentState = clone(this.#internalState);
62    const nextState = Object.assign(clone(currentState), clone(value))
63    this.#internalState = nextState
64    this.#pubsub.publish(currentState, nextState)
65    return nextState;
66  }
67    
68  subscribe(callback, config) {
69    return this.#pubsub.subscribe(callback, config)
70  }
71  
72}

کد تست قبلی را طوری تغییر می‌دهیم که تابع callback اول تنها زمانی فراخوانی شود که a در حالت تغییر یافته باشد و فراخوانی دوم نیز زمانی فراخوانی می‌شود که مقدار b تغییر یافته باشد.

1const store = new Store();
2
3const firstCallback = state => { console.log('first callback', state); };
4const firstConfig = state => { return { a: state.a }; };
5
6const secondCallback = state => { console.log('second callback', state) };
7const secondConfig = state => { return { b: state.b };};
8
9store.subscribe(firstCallback, firstConfig );
10store.subscribe(secondCallback, secondConfig );
11
12store.setState({a : 1});
13store.setState({a : 2});
14store.setState({b : 1});
15store.setState({b : 2});

زمانی که کد فوق را اجرا کنید، callback اول دو بار فراخوانی می‌شود و مقدار متناظر لاگ خواهد شد. سپس callback دوم فراخوانی می‌شود و تنها مقدار b را لاگ می‌کند که ابتدا 1 و سپس 2 است. مقدار a لاگ نمی‌شود، زیرا در تابع پیکربندی دوم تعریف نشده است.

بدین ترتیب به پایان این مقاله می‌رسیم و امیدواریم از مطالعه آن بهره آموزشی لازم را برده باشید. اگر نمی‌خواهید از babel برای بیلد کردن استفاده کنید، می‌توانید کد نهایی را بدون پارامتر کلاس خصوصی رها کنید.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
swlh
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *