حفظ State صفحات در Angular به کمک Ngrx

فریمورک های جاوا اسکریپتی توی چند سال اخیر تغییرات زیادی کردند و رقابت سنگینی بین اون ها شکل گرفته ، چند تا از اون ها مثل Angular ,React و Vuejs در سال های اخیر طرفدارهای زیادی پیدا کردند و هزاران مقاله برای مقایسه اون ها با هم تو سطح وب نوشته شده. اما من تقریبا تا اواخر سال ۹۶ سراغ هیچ کدوم از این فریمورک ها نرفته بودم و غالبا توی پروژه هایی که کار میکردم از JSP برای پیاده سازی لایه UI استفاده میکردیم و استدلالمون این بود که این فریمورک ها باید به یک سطح بلوغی برسند تا بتونیم برای پروژه هایی که داریم بریم سراغشون و بهشون اعتماد کنیم . به هر حال سال ۹۶ وقتی داشتیم تو شرکت یه پروژه جدید رو شروع می کردیم تصمیم گرفتیم که توی این پروژه از Angular برای پیاده سازی لایه UI استفاده کنیم . اون زمان که این کار رو شروع کردیم Angular روی نسخه ۴ بود و طی این چند سال چالش های متفاوتی برامون تو پیاده سازی لایه یو آی پیش اومد که هر کدومشون داستان های مفصل خودشو داشت . خوب لازمه کمی در مورد کاری که می کردیم توضیح بدم ، ما یه نرم افزار سازمانی توسعه می دادیم که کسب و کار های مربوط به سازمان های مختلف در حوزه انبارداری و نگهداری و تعمیرات را پوشش می داد و پر از فرم های CRUD بود ، بعد از مدتی متوجه شدیم یکی از مهم ترین چالش های ما توی پیاده سازی یو آی به کمک انگیولار حفظ کردن State صفحات است . چون ما یه سری فرم داشتیم که کاربر میومد داخل اون ها و بین هزاران رکورد به کمک کنترل های سرچ موجود ، در کامپوننت گرید رکورد مورد نظرش رو پیدا می کرد و وقتی وارد مود مشاهده اون رکورد می شد و دوباره به گرید برمیگشت همه تلاش هایی رو که برای پیدا کردن اون رکورد یا رکورد ها کرده بود باید تکرار می کرد ، چون همه اطلاعات سرچ از بین رفته بودند !!!! با یه مقدار سرچی که توی اینترنت کردم متوجه شدم که Redux راه حل این موضوع هست که به کمک کتابخانه Ngrx در انگیولار قابل پیاده سازی هست . در این مقاله فرض شده است که شما آشنایی ابتدایی با مفاهیم rxjs دارید . ریداکس (Redux ) چیست ؟ ریداکس (Redux) یک پترن برای مدیریت کردن State نرم افزار است . این Pattern بسیار شبیه به پترن Flux که متعلق به Facebook ئه هست و همین طور از Flux الهام گرفته است . شاید الان برای شما هم مثل من این سوال به وجود آمده باشه که اصلا State چی هست ؟ به طور کلی به مجموعه ی اطلاعاتی در مورد View یک Application مثل نمایش یا عدم نمایش فیلدها و المنت های مختلف State گفته میشه . هدف اصلی Redux فراهم کردن یک Predictable State Container است . ترجمه مناسب این جمله را واقعا نمی دونستم چی بنویسم ولی اگر بخوایم ترجمه ای براش ارائه بدیم باید بگیم یک کانتینر قابل پیش بینی وضعیت !!! اما نکته ای که به نظرم بیشتر از ترجمه اش مهم باشه اینه که اصلا مفهوم این جمله چیه ؟ در جواب این سوال باید گفت که مفاهیم موجود در Redux و متوجه شدن اون هاست که به ما کمک می کنه مفهوم این جمله رو متوجه بشیم ، اما برای ادامه کار باید همین قدر بدونیم که هر اتفاقی که توی سطح Application میفته ، ما میدونیم که این اتفاق وضعیت Application رو به چه چیزی تغییر میده . در ادامه سعی می کنیم به قواعد Redux بپردازیم تا بتونیم مفهوم این جمله رو بهتر درک کنیم . قواعد اصلی Redux ریداکس شامل سه قاعده اساسی هست که به کمک این سه قاعده معرفی میشه . Single source of truthتنها یک منبع حقیقت وجود دارد و آن هم Store است . تمام State های مربوط به Application در یک درخت وضعیت ذخیره خواهند شد .( اینکه Store چی هست جلوتر باید باهاش آشنا بشیم . )State is read-only تنها راه تغییر دادن State موجود ایجاد Action جدید است ،‌در غیر این صورت state های موجود در Store فقط خواندنی هستند . ( و باز هم در مورد Action جلوتر صحبت خواهیم کرد . ) Changes are made with pure functionsتغییرات در Store به کمک Reducer ها اتفاق می افتد که آن ها هم Pure Function هستند ( در مورد reducer ها و Pure Function ها توضیح خواهم داد . ) با NgRx بیشتر آشنا بشیمدر تصویر زیر روال اصلی Ngrx برای مدیریت state صفحات رو می بینیم که هر کدوم از این بخش ها عملیاتی رو انجام میدن و ما رو به هدف نهایی می رسونن . در این قسمت سعی می کنم گام به گام این مراحل رو توضیح بدم . در ابتدا سعی میکنم شکل بالا را بدون در نظر گرفتن مفاهیم جزئی هر المنت توضیح بدم . به صورت کلی شروع تغییرات از component شروع میشود پس گام اول کامپوننت می باشد . هر تغییری در کامپوننت باعث ایجاد یک action میشود (اصطلاحا کامپوننت ما یک action را dispatch می کنه ) در این نقطه دو مرحله ممکن است اتفاق بیفتد که به action ما بستگی دارد۱- اکشن ما Effect ای دارد :نکته : منظور از Effect این است که آیا برای مثال API Call ای شکل میگیرد یا خیر در این حالت Effect مورد نظر از طریق service مربوطه انجام می شود و سپسaction توسط reducer در Store ذخیره میکند (مثال : زدن دکمه جستجو )۲-اکشن ما Effect ای ندارد:در این حالت reducer مستقیما تغییرات (action) را در store ذخیره میکند (مثال : نمایش کد کالا با فشرده شدن checkbox)در نهایت تغییرات توسط کاموپوننت و به واسطه selector ها دریافت میشود.حال با جزئیات بیشتری به موارد موجود در عکس میپردازیمدر اکثر حالت ها تغییرات از Component های ما شروع می شود ، در نظر بگیرید که ما در یک صفحه چک باکسی بر روی View داریم ( مثلا مشاهده رکورد های فعال و غیر فعال) و کلیک کردن کاربر بر روی این چک باکس منجر به dispatch شدن یک Action می شود . نمونه ای از یک چک باکس در حالت غیر فعالنمونه ای از یک چک باکس در حالت فعال Action اتفاق افتادن هر رخداد در کامپوننت های Angular موجب ایجاد شدن یک Action خواهد شد . هر Action دارای دو مشخصه اساسی است : مشخصه Type : یک رشته خواندنی است که نوع action را مشخص میکند برای مثال فرض کنیم میخواهیم در صورتی که روی یک چک باکس در view کلیک کردیم و مقدار آن را به فعال تغییر دادیم ، تنها رکورد هایی که دارای وضعیت فعال هستند را نمایش دهیم . برای این کار باید مشخصه type را با مقدار SHOW_ACTIVE_RECORDS مقدار دهی کنیم ( دقت کنید که مقدار این مشخصه توسط شما تعیین می شود و میتواند هر چیزی باشد در حقیقت اسم اکشن می باشد )مشخصه Payload : نوع و مقدار این مشخصه بستگی به اطلاعاتی دارد که این Action می خواهد به Reducer بفرستد دارد ، در مثالی که ما ذکر کردیم می تواند خالی باشد و یا با یک مقدار true/false مقدار دهی شود . Reducer همانطور که قبلا اشاره شد Reducer ها Pure Function هایی هستند که دارای دو ورودی هستند ، یک ورودی State قبلی و ورودی دوم یک Action خواهد بود . Pure Functions قبلا اشاره کردیم که Reducer ها از دسته ی Pure Function ها هستند ، اما در مورد این نوع از توابع تعریفی ارائه نکردیم . این توابع ، توابعی هستند که مقدار خروجی آن ها مستقیما به مقدار ورودی آن ها بستگی دارد ، در نتیجه اگر شما یک Pure Function را با یک مقدار خاص فراخوانی کنید همیشه خروجی یکسان خواهید داشت . برای مثال تابع زیر مثالی از یک Pure Function است : function calculateSquareArea(x) { return x * x; }در مقابل Pure Function ها ، Impure Function ها قرار دارند که مقدار خروجی آن ها مستقیما از ورودی آن ها تاثیر نمی گیرد و ممکن است با یک ورودی خاص در مواقع مختلف خروجی های متفاوتی داشته باشند ، توابعی مانند Date.now , Math.random مثالی از Impure Function ها هستد . اگر Action مورد نظر Effect ای ایجاد نکند : Reducer روال بررسی و پردازش این Action را شروع کرده ( غالبا به کمک دستورات switch/case ) و State جدید را به واسطه Selector ها به Component بر می گرداند که حاصل ترکیب State قبلی با اتفاقاتی که در این Action رخ داده ، خواهد بود . اگر Action مورد نظر Effect ای ایجاد کند : به عنوان مثال به اجرا شدن یک سرویس http برای دریافت اطلاعات می تواند یک Effect باشد. اگر Action ما دارای تاثیرات یا Effect باشد باید آن ها قبل از اجرا شدن Reducer بررسی و اجرا بشوند سپس Action مورد نظر جهت پردازش در اختیار Reducer قرار می گیرد .Effectsدر اکوسیستم Ngrx به کمک Effect ها به این امکان دست پیدا خواهیم کرد که با تاثیراتی که خارج از روال انگیولار و کامپوننت های آن هستند تعامل داشته باشیم . Effect ها دقیقا مشابه Reducer ها به اتفاقاتی که در یه App می افتد گوش می دهند و بلافاصله بعد از Dispatch شدن یک Action اگر شرایط تعریف شده را داشته باشند تاثیرات لازم بر روی آن Action را انجام می دهند که غالبا این تاثیرات ارسال یا دریافت اطلاعات به / از یک Api است . در نهایت Action دیگری را با شرایط جدید Dispatch خواهند کرد . لازم است دقت کنیم که بعد از پایان یک Effect یک Action جدید با توجه به failed یا success شدن آن Effect ایجاد خواهد شد . Storeاساسی ترین بخش Redux همین Store است ، که تمام توضیحاتی را که ارائه دادیم را در کنار هم قرار می دهد . ( Action,reducer ) و مهم ترین کار را انجام می دهد : وضعیت Application را حفظ می کند . به بیان ساده ظرفی که تمام اطلاعات ما را در خود نگه می دارد Store است . حالا Store وضعیت يا State جدید را در خود دارد . اینState می تواند یک Object بسیار بزرگ باشد ، از این رو برای خواندن يک Slice از State ها و استفاده از آن در کامپوننت های مورد نظر Selector ها در کتابخانه Ngrx معرفی می شوند . Selectors همانطور که اشاره کردیم State می تواند یک درخت بسیار بزرگ باشد ، و قطعا معنی نمی دهد در هر نقطه از کد ما تمام این Object یا درخت را داشته باشیم ، چرا که هر بار به بخشی از آن احتیاج داریم . کتابخانه Ngrx برای ما تابع select را فراهم کرده که بخشی از State ها را برای ما Fetch کرده و تغییرات لازم را انجام داده و در اختیار ما قرار می دهد . نصب کتابخانه Ngrx برای اضافه کردن و نصب Ngrx بر روی Application خودمون از دستور زیر استفاده می کنیم : ( دقت کنید که در هنگام اجرای این دستور کامپیوتر شما باید به اینترنت متصل باشد . )npm install @ngrx/store --saveبدین ترتیب کتابخانه Ngrx بر روی Application ما نصب و به فایل package.json اضافه خواهد شد . ایجاد يک Storeبرای ایجاد یک Store در سطح Application اگر تنها یک module داشته باشیم و آن هم App.module.ts باشد به شکل زیر عمل خواهیم کرد ( قسمتی که باید اضافه شود در کد زیر Underline شده است ) : import {StoreModule} from '@ngrx/store';@NgModule({ bootstrap: [AppComponent], declarations: [ AppComponent ], imports: [ StoreModule.forRoot(reducer), ], exports: [ ], providers: [ ]})export class AppModule { constructor() { }} در صورتی که reducer خاصی در App.module نداریم می توانیم نحوه تعریف را به شکل زیر تغییر بدهیم : StoreModule.forRoot({})نکته قابل توجه این است که در صورت استفاده از Lazy Module در Angular بایستی به ازای هر کدام از module ها تعریف Store را انجام دهیم ، نحوه تعریف Store برای Lazy Module ها ، به عنوان مثال ماژول مربوط به فرم products به شکل زیر عمل می کنیم : StoreModule.forFeature('products', reducers)در این حالت هم در صورت عدم نیاز به reducer به شکل زیر عمل خواهیم کرد : StoreModule.forFeature('products', {})ساختار بندی پوشه ها برای Storeیکی از مهم ترین موضوعات برای داشتن یک Codebase منظم و تمیز استفاده از یک ساختار بندی استاندارد هست ، در مورد ساختار پوشه هایی که برای Store احتیاج هست روش های مختلفی توصیه شده اما من ترجیح میدم به ازای هر Domain Class در فایل ها یک فولدر داشته باشم و داخل اون یک پوشه به نام Store مانند ساختار زیر بسازم: ساختار مناسب فولدرها برای مدیریت Stateاستخراج State ها مهم ترین موضوع در استفاده از Redux و Ngrx استخراج State های App مورد نظر است و همچنین Action هایی که منجر به تغییر این State ها می شوند . برای مثال به تصویر زیر دقت کنید : بخش جستجو در فرم کاربراناین تصویر قسمت جستجو مربوط به فرم مشاهده کاربران می باشد ، کاربر از طریق کنترل های موجود می تواند بر اساس نام ، نام خانوادگی و یا نام کاربری در بین کاربران اقدام به جستجو نماید . دکمه های "جستجو و پاک کردن" می تواند منجر به ایجاد یک Action شود . تا اینجای کار تقریبا با مفاهیم موجود آشنا شدید و همینطور کمی هم با مفاهیم استفاده از Ngrx در سطح کد ، اما برای استفاده از این Library روش های مختلفی وجود دارد که هر کدوم مزایا و معایب خودشون رو ممکنه داشته باشند اما به هر حال روشی که من استفاده می کنم رو سعی می کنم به صورت گام به گام و بر اساس فایل هایی که قبل تر گفتم توی پوشه Store باید بزاریم ، توضیح میدم . product.action.ts در این فایل باید در ابتدا یک enum تعریف کنیم که تمام Action هایی که ممکن است اتفاق بیفتد را را در آن تعریف کرده باشیم : export enum ProductActionTypes { Search = '[Product] Search', SearchComplete = '[Product] Search Complete', SearchError = '[Product] Search Error' }در گام بعدی در همین فایل باید به ازای هر کدام از مقادیر این enum کلاس هایی را بنویسیم که اینترفیس Action را از مسیر '@ngrx/store' در کتابخانهNgrx پیاده سازی می کنند : import {Action} from '@ngrx/store'; export class SearchComplete implements Action { readonly type = ProductActionTypes.SearchComplete; constructor(public payload: Product[]) { } }همان طور که قبل تر در مورد Action ها توضیح دادم دو بخش مهم یک اکشن type و payload آن هستند و همان طور که در کد بالا می بینید در constructor این کلاس payload مربوط اکشن SearchComplete وجود دارد و آن هم لیستی از اطلاعات مربوط به دیتا مدل Product است و همین طور مقدار type از enum مربوطه مقدار دهی کردیم . product.efftects.tsهمان طور که اشاره کردیم Effect ها مسئول ارتباط با Api ها هستند ، نکته ای که وجود دارد این است که کلاس های Effect بایستی دارای @Injectable باشند . import {Actions, Effect, ofType} from '@ngrx/effects'; import {catchError, debounceTime, map, skip, switchMap, takeUntil} from 'rxjs/operators'; import {Action} from '@ngrx/store'; @Injectable() export class ProductEffects { constructor( private actions$: Actions, private productService: ProductService, @Optional() @Inject(SEARCH_DEBOUNCE) private debounce: number, @Optional() @Inject(SEARCH_SCHEDULER) private scheduler: Scheduler ) { } @Effect() search$: Observable<Action> = this.actions$.pipe( ofType<Search>(ProductActionTypes.Search), debounceTime(this.debounce || 300, this.scheduler || asyncScheduler), map(action => action.payload), switchMap(query => { if (query === undefined || query === null) { return empty(); } const nextSearch$ = this.actions$.pipe( ofType(ProductActionTypes.Search), skip(1) ); return this.productService.get(query).pipe(takeUntil(nextSearch$), map((products: Product[]) => new SearchComplete(products)), catchError(err => of(new SearchError(err))) ) }) ); }در این کد در واقع ما به اتفاقاتی که در Component مورد نظر می افتد گوش می دهیم و اکشن هایی که منجر به ایجاد Effect می شود را شناسایی کرده و تاثیرات لازم را اعمال می کنیم .product.reducer.tsimport {createEntityAdapter, EntityAdapter, EntityState} from '@ngrx/entity'; export interface State extends EntityState<Product> { selectedProductId: string | null; } export const adapter: EntityAdapter<Product> = createEntityAdapter<Product>({ selectId: (product: Product) => product.id, sortComparer: false, }); export const initialState: State = adapter.getInitialState({ selectedProductId: null, }); export function reducer(state = initialState, action: ProductActionsUnion): State { switch (action.type) { case ProductActionTypes.SearchComplete: default: { return state; } } }و در نهایت در فایل search.reducer.ts هم مشابه فایل product.reducer.ts برای مدیریت بخش search reducer هایی که لازم است را می نویسیم . index.tsدر این فایل تمام selector های مورد نیاز برای استخراج state های مورد نظر از Store را می نویسیم ،همین طور دو فایل product.reducer.ts و search.reducer.ts را در این کلاس import می کنیم که در component ها فقط با import کردن product/store/reducers به selector ها و reducer های مربوطه دسترسی داشته باشیم .نمونه ای از یک Selector به صورت زیر خواهد بود : import {createSelector} from '@ngrx/store'; export const getProductEntitiesState = createSelector( getProductsState, state => state.products );در کامپوننت ها چه تغییراتی باید بدهیم ؟ بعد از انجام تمام این کارها در کامپوننت های مورد نظر باید تغییراتی را اعمال کنیم تا بتونیم اطلاعات مورد نیاز را به کمک selector ها از store خوانده و کامپوننت را با وضعیت مورد نظر به روز کنیم. به ازای هر کدام از selector ها باید یک Observable تعریف کنیم که اطلاعات مورد نظر را از store فراخوانی کرده و در اختیار ما قرار دهد : @Component({ selector: 'product-list', templateUrl: './product.component.html' }) export class ProductComponent { products$: Observable<Product[]>; constructor() { this.products$ = store.pipe(select(productReducer.getSearchResults)); // نحوه تعریف selector } } جمع بندی به طور کلی موضوع مدیریت State صفحات به کمک Redux به نظر من موضوع پیچیده ای برای برنامه نویس ها هست ، و نیاز هست که اون ها موضوعات و جزئیات مختلفی رو بهش تسلط داشته باشند تا بتونن این کار رو انجام بدن ، به اضافه اینکه این روش کدهای زیادی رو هم تولید میکنه اما در حال حاضر به نظر میرسه روشی باشه که اکثر حالات را پوشش بده ، البته لازم به ذکر هست که کتابخونه های دیگه ای هم به غیر از Ngrx برای مدیریت این موضوع وجود داره (مثل Ngxs و Akita) و شما می تونید اون ها رو هم بررسی کنید . اما من سعی کردم اینجا تجربه ای که توی این حوزه داشتم را مکتوب کنم تا طی تکامل فریمورک های جاوا اسکریپتی ( که احتمالا روشی ساده تر برای این موضوع ارائه خواهد شد ) در آینده ، هم خودم و هم دوستانی که این مقاله رو خوندند بتونیم مقایسه ی بهتری بین روش های موجود انجام بدیم .منتظر نظرات و پیشنهادات شما هستم . منابع :Angular: NGRX a clean and clear IntroductionPure vs Impure functionsAngular NgRx: Getting StartedNgRx in AG04