جمع‌بندی تغییرات immutable و واحد کردن action ها

?How to Unify Actions

22 بهمن 1399
جمع بندی تغییرات immutable و واحد کردن action ها

به روز رسانی اشیاء تو در تو (nested)

در دو قسمت قبل زیاد در مورد تغییر دادن اشیاء و آرایه ها به صورت immutable صحبت کردیم و من می خواهم در این قسمت کوتاه نکات گفته شده در دو قسمت قبل را به صورت خلاصه جمع بندی کنم. مورد اول به روز رسانی اشیاء تو در تو (nested) است. نکته مهم در رابطه با این نوع اشیاء این است که هر سطح یا بُعد از شیء باید جداگانه کپی شود. بیایید چند مورد از روش های غلط را ببینیم. اولین کار اشتباهی که برنامه نویسان انجام می دهند این است که فکر می کنند با ایجاد یک متغیر جدید و انتساب شیء ای به آن، یک شیء جدید به وجود می آید. همانطور که گفتیم اشیاء و آرایه ها در جاوا اسکرپیت reference-type هستند بنابراین متغیر جدید فقط به مکان همان شیء قبلی در حافظه اشاره می کند (به زبان ساده فقط یک ارجاع است و چیزی را کپی نمی کند). به طور مثال:

function updateNestedState(state, action) {
    let nestedState = state.nestedState;
    // ERROR: this directly modifies the existing object reference - don't do this!
    nestedState.nestedField = action.data;

    return {
        ...state,
        nestedState
    };
}

کد بالا کاملا غلط بوده و state اصلی را تغییر می دهد. با اینکه این تابع یک کپی صحیح و مستقل از state را بر می گرداند (اپراتور spread) اما از آنجایی که متغیر nestedState به همان شیء اصلی اشاره می کرده است، باز هم state به صورت مستقیم و غیر immutable تغییر می کند.

اشتباه دوم برنامه نویسان مبتدی این است که از سطح های بعدی شیء کپی نمی گیرند:

function updateNestedState(state, action) {
    // Problem: this only does a shallow copy!
    let newState = { ...state };

    // ERROR: nestedState is still the same object!
    newState.nestedState.nestedField = action.data;

    return newState;
}

در قسمت های قبلی توضیح دادم که استفاده از اپراتور spread، شیء را deep clone نمی کند و در مثال بالا می بینیم که از newState.nestedState استفاده شده است بنابراین می فهمیم که شیء newState یک شیء دیگر به نام nestedState درون خود داشته است اما ما فقط state را کپی کرده ایم و کاری به nestedState نداشته ایم. بدین شکل state اصلی را تغییر داده ایم!

روش صحیح چیست؟ فرض کنید کدی به شکل state.first.second[someId].fourth داشته باشیم. برای کپی کردن چنین کدی به صورت deep (تمام سطوح شیء) باید به شکل زیر عمل کنیم:

function updateVeryNestedField(state, action) {
    return {
        ...state,
        first : {
            ...state.first,
            second : {
                ...state.first.second,
                [action.someId] : {
                    ...state.first.second[action.someId],
                    fourth : action.someValue
                }
            }
        }
    }
}

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

اضافه کردن یا حذف کردن آیتم ها از یک آرایه

در حالت عادی برای تغییر اعضای یک آرایه از توابعی مثل push و splice و unshift استفاده می کنیم اما تمام این موارد آرایه اصلی را تغییر می دهند (تمام آن ها توابع mutative هستند). یک راه حل برای حذف یا اضافه کردن آیتم ها به آرایه استفاده از روش زیر است:

function insertItem(array, action) {
    return [
        ...array.slice(0, action.index),
        action.item,
        ...array.slice(action.index)
    ]
}

function removeItem(array, action) {
    return [
        ...array.slice(0, action.index),
        ...array.slice(action.index + 1)
    ];
}

نکته مهم و اصلی در تمام این مراحل این است که «ارجاع اصلی در حافظه برنامه» مستقیما تغییر نکند. یعنی تا زمانی که کپی خاصی داریم و ارجاع اصلی را تغییر ندهیم هیچ مشکلی پیش نیامده و روش استفاده شده به خود شما بستگی دارد. به طور مثال برخی از برنامه نویسان از روش زیر استفاده می کنند که کاملا صحیح است:

function insertItem(array, action) {
    let newArray = array.slice();
    newArray.splice(action.index, 0, action.item);
    return newArray;
}

function removeItem(array, action) {
    let newArray = array.slice();
    newArray.splice(action.index, 1);
    return newArray;
}

استفاده از slice باعث می شود که یک آرایه جدید دریافت کنیم و مسئله مهم همین است، ایجاد یک کپی مستقل از ارجاع اصلی در حافظه برنامه! همچنین روش دیگری برای نوشتن تابع حذف وجود دارد:

function removeItem(array, action) {
    return array.filter( (item, index) => index !== action.index);
}

به روز رسانی یک آیتم در آرایه ها

به روز رسانی (نه حذف و نه اضافه کردن، بلکه ویرایش آیتمی که از قبل وجود دارد) یک آیتم در آرایه ها معمولا با استفاده از متد Array.map انجام می شود به صورتی که یک value یا مقدار جدید را برای آیتم مورد نظر خود برگردانده و برای بقیه موارد خودشان را برمی گردانیم:

function updateObjectInArray(array, action) {
    return array.map( (item, index) => {
        if(index !== action.index) {
            // This isn't the item we care about - keep it as-is
            return item;
        }

        // Otherwise, this is the one we want - return an updated value
        return {
            ...item,
            ...action.item
        };    
    });
}

برای اطلاعات بیشتر به آدرس زیر مراجعه کنید:

http://redux.js.org/docs/recipes/reducers/ImmutableUpdatePatterns.html

کتابخانه ها و ابزار کمکی

از آنجایی که تغییر immutable معمولا کار سخت و طاقت فرسایی است، انواع کتابخانه ها و ابزار کمکی برای انجام این تغییرات ساخته شده اند و هر کدام syntax و API خاص خودش را دارد. به طور مثال کتابخانه dot-prop-immutable دستورات رشته ای را دریافت می کند:

state = dotProp.set(state, `todos.${index}.complete`, true)

یا immutability-helper (یک fork از React Immutability Helpers که در حال حاضر منسوخ شده است) از مقادیر تو در تو و توابع کمکی استفاده می کند:

var collection = [1, 2, {a: [12, 17, 15]}];
var newCollection = update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});

در واقع addon های بسیار زیادی توسط مجموعه redux در صفحه addon های redux در سایتشان معرفی شده است که برای تغییر immutable می توانید به این قسمت از سایت گیت هاب از آن مراجعه کنید.

جمع کردن action ها در یک فایل

اگر نگاهی به reducer.js بیندازید متوجه خواهید شد که ما case های مختلفی را برای تک تک action.type هایمان داریم و این action.type ها همان مقادیری هستند که در Counter.js آن ها را تعریف کرده بودیم. این مسئله ممکن است باعث مشکلات مختلفی بشود، به طور مثال اگر تصادفا یک حرف کوچک را جا بیندازیم و INCREMENT را به صورت INCRMENT بنویسیم دیگر action ما شناسایی نخواهد شد و برنامه بهم می ریزد.

به همین خاطر است که پیشنهاد می شود action.type هایتان را درون Constant هایی قرار دهید تا همیشه از همان constant ها به صورت ثابت استفاده کنید و نیاز به تایپ اسم action.type نداشته باشید. ممکن است با خودتان بگویید احتمال ایجاد چنین خطایی بسیار کم است اما باید توجه کنید که ما در یک برنامه تست و بسیار ساده کار می کنیم. زمانی که برنامه شما بزرگ شده و روی یک پروژه واقعی کار کنید متوجه می شوید که تعداد action ها بسیار زیاد می شود و علاوه بر احتمال تایپ اشتباه، شلوغی و یکجا نبودن آن ها نیز باعث آزار خودتان خواهد شد.

برای انجام این کار وارد پوشه store شده و یک فایل جدید به نام actions.js بسازید. حالا action.type های خود را به صورت یک رشته export می کنیم:

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const ADD = 'ADD';
export const SUBTRACT = 'SUBTRACT';
export const STORE_RESULT = 'STORE_RESULT';
export const DELETE_RESULT = 'DELETE_RESULT';

البته نیازی نیست که نام ثابت خود را دقیقا مانند نام رشته انتخاب کنید اما برای سر در گم نشدن بین انبوه کدها روش بهتری همین روش است. در قدم بعد باید به reducer.js رفته و action های خود را در آن import کنیم:

import * as actionTypes from './actions';

شاید این نوع import برایتان عجیب باشد به همین خاطر باید برایتان توضیح بدهم. همانطور که می دانید در اکثر زبان های برنامه نویسی علامت ستاره (*) برابر با «هر چیز» یا «همه چیز» است. همچنین برای import کردن موارد مختلف می توانیم از دو نوع import استفاده کنیم، مورد اول همان حالتی است که در تمامی قسمت های قبل استفاده کرده ایم؛ یک عنصر خاص وجود دارد و ما آن را با نام وارد می کنیم، اما در مورد دوم می توانیم alias یا یک نام مستعار برای import خود تعیین کنیم. در حالتی که از علامت ستاره استفاده می کنیم حتما باید از روش دوم استفاده کنیم چرا که هیچ نامی برای import ما وجود نخواهد داشت تا بتوانیم آن را درون کدها قرار بدهیم. من در بالا نام دلخواه actionType را انتخاب کرده ام بنابراین دستور import بالا می گوید: تمام موارد موجود در فایل actions.js را با نام actionTypes وارد کن. حالا actionTypes یک فایل جاوا اسکریپتی است که تمام ثابت های ما را درون خود دارد، به صورتی که نام const ها برابر با خصوصیت (property) این شیء هستند.

بنابراین می توانیم دستور switch درون reducer را به شکل زیر و به راحتی تغییر دهیم:

const reducer = (state = initialState, action) => {
    switch (action.type) {
        case actionTypes.INCREMENT:
            const newState = Object.assign({}, state);
            newState.counter = state.counter + 1;
            return newState;
        // return {
        //     ...state,
        //     counter: state.counter + 1
        // }
        case actionTypes.DECREMENT:
            return {
                ...state,
                counter: state.counter - 1
            }
        case actionTypes.ADD:
            return {
                ...state,
                counter: state.counter + action.val
            }
        case actionTypes.SUBTRACT:
            return {
                ...state,
                counter: state.counter - action.val
            }
        case actionTypes.STORE_RESULT:
            return {
                ...state,
                results: state.results.concat({ id: new Date(), value: state.counter })
            }
        case actionTypes.DELETE_RESULT:
            const updatedArray = state.results.filter(result => result.id !== action.resultElId);
            return {
                ...state,
                results: updatedArray
            }
    }
    return state;
};

در مرحله بعد باید وارد counter.js شوم و مقادیر تایپی آنجا را نیز با ثابت هایی که تعریف کردیم جایگزین کنم. بنابراین ابتدا actionTypes را وارد counter.js می کنیم:

import * as actionTypes from '../../store/actions';

حالا به mapDispatchToProps می رویم و تمام رشته ها را با ثابت های خودمان جابجا می کنیم:

const mapDispatchToProps = dispatch => {
    return {
        onIncrementCounter: () => dispatch({ type: actionTypes.INCREMENT }),
        onDecrementCounter: () => dispatch({ type: actionTypes.DECREMENT }),
        onAddCounter: () => dispatch({ type: actionTypes.ADD, val: 10 }),
        onSubtractCounter: () => dispatch({ type: actionTypes.SUBTRACT, val: 15 }),
        onStoreResult: () => dispatch({ type: actionTypes.STORE_RESULT }),
        onDeleteResult: (id) => dispatch({ type: actionTypes.DELETE_RESULT, resultElId: id })
    };
};

کدها را ذخیره کرده و به مرورگر بروید. همه چیز باید مثل قبل و بدون خطا کار کند. در صورتی که به خطا برخورد کردید، کدهایتان را دوباره بررسی کنید تا مشکل را پیدا کنید، شاید قسمتی را به اشتباه تایپ کرده باشید!

تمام فصل‌های سری ترتیبی که روکسو برای مطالعه‌ی دروس سری دوره جامع آموزش ری اکت توصیه می‌کند:
نویسنده شوید
دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.