Един аспект на разработката на JavaScript, с който много разработчици се борят, е работата с незадължителни стойности. Кои са най-добрите стратегии за минимизиране на грешките, причинени от стойности, които могат да бъдат null, undefined или по друг начин неинициализирани по време на изпълнение?

Някои езици имат вградени възможности за тези обстоятелства. В някои статично въведени езици можете да кажете, че null и undefined са незаконни стойности и да оставите езика ви за програмиране да изведе TypeError по време на компилиране, но дори и в тези езици това не може да попречи на нулеви входове да влязат в програмата по време на изпълнение.

За да се справим по-добре с този проблем, трябва да разберем откъде могат да дойдат тези стойности. Ето някои от най-често срещаните източници:

  • Въвеждане от потребителя
  • База данни/мрежови записи
  • Неинициализирано състояние
  • Функции, които не могат да върнат нищо

Въвеждане от потребителя

Когато се занимавате с въвеждане от потребителя, валидирането е първата и най-добра линия на защита. Често разчитам на валидатори на схеми, за да ми помогнат с тази работа. Например, вижте react-jsonschema-form.

Хидратиращи записи от входа

Винаги предавам входни данни, които получавам от мрежата, базата данни или потребителски вход, през хидратираща функция. Например, ще използвам redux action creators, които могат да обработват undefined стойности, за да хидратират потребителските записи:

const setUser = ({ name = 'Anonymous', avatar = 'anon.png' } = {}) => ({
  type: setUser.type,
  payload: {
    name,
    avatar
  }
});
setUser.type = 'userReducer/setUser';

Понякога ще трябва да покажете различни неща в зависимост от текущото състояние на данните. Ако е възможно да се покаже страница, преди всички данни да бъдат инициализирани, може да се окажете в тази ситуация. Например, когато показвате парични салда на потребител, можете случайно да покажете баланс от $0, преди данните да се заредят. Виждал съм това да разстройва потребителите няколко пъти. Можете да създадете потребителски типове данни, които генерират различни изходи въз основа на текущото състояние:

const createBalance = ({
  // default state
  state = 'uninitialized',
  value = createBalance.empty
} = {}) => createBalance.isValidState(state) && ({
  __proto__: {
    uninitialized: () => '--',
    initialized: () => value,
    format () {
      return this[this.getState()](value);
    },
    getState: () => state,
    set: value => {
      const test = Number(value);
      assert(!Number.isNaN(test), `setBalance Invalid value: ${ value }`);
      return createBalance({
        state: 'initialized',
        value
      });
    }
  }
});
createBalance.empty = '0';
createBalance.isValidState = state => {
  if (!['uninitialized', 'initialized'].includes(state)) {
    throw new Error(`createBalance Invalid state: ${ state }`);
  }
  return true;
};
const setBalance = value => createBalance().set(value);
const emptyBalanceForDisplay = createBalance()
  .format();
console.log(emptyBalanceForDisplay); // '--'
const balanceForDisplay = setBalance('25')
  .format(balance);
console.log(balanceForDisplay); // '25'
// Uncomment these calls to see the error cases:
// setBalance('foo'); // Error: setBalance Invalid value: foo
// Error: createBalance Invalid state: THIS IS NOT VALID
// createBalance({ state: 'THIS IS NOT VALID', value: '0' });

Кодът по-горе е машина за състояния, която прави невъзможно показването на невалидни състояния. Когато за първи път създадете баланса, той ще бъде настроен на състояние uninitialized. Ако се опитате да покажете баланс, докато състоянието е uninitialized, вместо това винаги ще получавате заместваща стойност ('--').

За да промените това, трябва изрично да зададете стойност, като извикате метода .set или прекия път setBalance, който сме дефинирали под фабриката createBalance.

Самото състояние е „капсулирано“, за да се защити от външна намеса, за да се гарантира, че други функции не могат да го хванат и да го настроят в невалидно състояние.

Забележка: Ако се чудите защо използваме низове вместо числа за това, това е, защото представям типове пари с големи числови низове с много десетична точност, за да избегна грешки при закръгляване и да представя точно стойностите за транзакции с криптовалута, които може да има произволна значима десетична точност.

Ако използвате Redux или Redux архитектура, можете да декларирате държавни машини с Redux-DSM.

Избягвайте да създавате null и undefined стойности

Във вашите собствени функции можете да избегнете създаването на null или undefined стойности като начало. Има няколко начина да направите това, вградени в JavaScript, които идват на ум. Виж отдолу.

Избягвайте нула

Никога не създавам изрично null стойности в JavaScript, защото никога не съм виждал смисъла да имам две различни примитивни стойности, които по същество означават „тази стойност не съществува“.

От 2015 г. JavaScript поддържа стойности по подразбиране, които се попълват, когато не предоставите стойност за въпросния аргумент или свойство. Тези настройки по подразбиране не работят за стойности null. Според моя опит това обикновено е грешка. За да избегнете този капан, не използвайте null в JavaScript.

Ако искате специални случаи за неинициализирани или празни стойности, държавните машини са по-добър залог. Виж по-горе.

Нови функции на JavaScript

Има няколко функции, които могат да ви помогнат да се справите със стойности null или undefined. И двете са предложения от етап 3 към момента на писане, но ако четете от бъдещето, може да успеете да ги използвате.

Към момента на писане на тази статия незадължителното верижно свързване е предложение за етап 3. Работи така:

const foo = {};
// console.log(foo.bar.baz); // throws error
console.log(foo.bar?.baz) // undefined

Nullish Coalescing Operator

Също така предложение за етап 3, което трябва да бъде добавено към спецификацията, „нулев обединяващ оператор“ е основно фантастичен начин да се каже „оператор на резервна стойност“. Ако стойността отляво е undefined или null, тя се оценява на стойността отдясно. Работи така:

let baz;
console.log(baz); // undefined
console.log(baz ?? 'default baz');
// default baz
// Combine with optional chaining:
console.log(foo.bar?.baz ?? 'default baz');
// default baz

Ако бъдещето все още не е настъпило, ще трябва да инсталирате @babel/plugin-proposal-optional-chaining и @babel/plugin-proposal-nullish-coalescing-operator.

Асинхронно или с обещания

Ако функция може да не върне със стойност, може да е добра идея да я обвиете в Either. Във функционалното програмиране монадата Either е специален абстрактен тип данни, който ви позволява да прикачите два различни кодови пътя: успешен път или неуспешен път. JavaScript има вграден асинхронен Either монаден тип данни, наречен Promise. Можете да го използвате, за да направите декларативно разклоняване на грешки за недефинирани стойности:

const exists = x => x != null;
const ifExists = value => exists(value) ?
  Promise.resolve(value) :
  Promise.reject(`Invalid value: ${ value }`);
ifExists(null).then(log).catch(log); // Invalid value: null
ifExists('hello').then(log).catch(log); // hello

Можете да напишете синхронна версия на това, ако искате, но не ми трябваше много. Оставям това като упражнение за вас. Ако имате добри познания във функтори и монади, процесът ще бъде по-лесен. Ако това звучи смущаващо, не се тревожете за това. Просто използвайте обещания. Те са вградени и работят добре през повечето време.

Масиви за Maybes

Масивите реализират map метод, който приема функция, която се прилага към всеки елемент от масива. Ако масивът е празен, функцията никога няма да бъде извикана. С други думи, масивите в JavaScript могат да изпълнят ролята на Maybes от езици като Haskell.

Какво е Може би?

Може би е специален абстрактен тип данни, който капсулира незадължителна стойност. Типът данни има две форми:

  • Просто — Може би, което съдържа стойност
  • Нищо — може би без стойност

Ето същината на идеята:

const log = x => console.log(x);
const exists = x => x != null;
const Just = value => ({
  map: f => Just(f(value)),
});
const Nothing = () => ({
  map: () => Nothing(),
});
const Maybe = value => exists(value) ?
  Just(value) :
  Nothing();
const empty = undefined;
Maybe(empty).map(log); // does not log
Maybe('Maybe Foo').map(log); // logs "Maybe Foo"

Това е само пример за демонстриране на концепцията. Бихте могли да изградите цяла библиотека от полезни функции около maybes, прилагайки други операции като flatMap и flat (напр., за да избегнете Just(Just(value)), когато композирате множество функции, връщащи Maybe). Но JavaScript вече има тип данни, който внедрява много от тези функции извън кутията, така че обикновено посягам към него вместо това: масивът.

Ако искате да създадете функция, която може или не може да произведе резултат (особено ако може да има повече от един резултат), може да имате чудесен случай за връщане на масив.

const log = x => console.log(x);
const exists = x => x != null;
const arr = [1,2,3];
const find = (p, list) => [list.find(p)].filter(exists);
find(x => x > 3, arr).map(log); // does not log anything
find(x => x < 3, arr).map(log); // logs 1

Намирам факта, че map няма да бъде извикан в празен списък, много полезен за избягване на null и undefined стойности, но не забравяйте, че ако масивът съдържа null и undefined стойности, той ще извика функцията с тези стойности, така че ако функцията, която можете да произведете null или undefined, ще трябва да ги филтрирате от върнатия масив, както е показано по-горе. Това може да доведе до промяна на дължината на колекцията.

В Haskell има функция maybe, която (като map) прилага функция към стойност. Но стойността не е задължителна и е капсулирана в Maybe. Можем да използваме типа данни Array на JavaScript, за да направим по същество същото нещо:

// maybe = b => (a => b) => [a] => b
const maybe = (fallback, f = () => {}) => arr =>
  arr.map(f)[0] || fallback;
// turn a value (or null/undefined) into a maybeArray
const toMaybeArray = value => [value].filter(exists);
// maybe multiply the contents of an array by 2,
// default to 0 if the array is empty
const maybeDouble = maybe(0, x => x * 2);
const emptyArray = toMaybeArray(null);
const maybe2 = toMaybeArray(2);
// logs: "maybeDouble with fallback:  0"
console.log('maybeDouble with fallback: ', maybeDouble(emptyArray));
// logs: "maybeDouble(maybe2):  4"
console.log('maybeDouble(maybe2): ', maybeDouble(maybe2));

maybe взема резервна стойност, след това функция за картографиране на може би масива, след това може би масив (масив, съдържащ една стойност или нищо) и връща или резултата от прилагането на функцията към съдържанието на масива, или резервната стойност, ако масивът е празен.

За удобство дефинирах и функция toMaybeArray и изпълних функцията maybe, за да я направя най-очевидна за тази демонстрация.

Ако искате да направите нещо подобно в производствения код, създадох единично тествана библиотека с отворен код, за да го улесня. Нарича се Maybearray. Предимството на Maybearray пред другите библиотеки на JavaScript Maybe е, че използва естествени JavaScript масиви за представяне на стойности, така че не е нужно да им давате специално отношение или да правите нещо специално, за да конвертирате напред и назад. Когато срещнете Maybe масиви при отстраняването на грешки, не е нужно да питате „какъв е този странен тип?!” Това е просто масив от стойност или празен масив и сте ги виждали милиони пъти преди.

Следващи стъпки

Има много повече съдържание на EricElliottJS.com, включително много видеоклипове, упражнения, записани скрийнкастове и бързи съвети. Ако не сте член, сега е чудесен момент да видите какво сте пропуснали!

Ерик Елиът е автор на книгите Съставяне на софтуер и Програмиране на JavaScript приложения. Като съосновател на EricElliottJS.com и DevAnywhere.io, той учи разработчиците на основни умения за разработка на софтуер. Той изгражда и съветва екипи за разработка на крипто проекти и е допринесъл за софтуерния опит за Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC, и най-добрите звукозаписни изпълнители, включително Usher, Frank Ocean, Metallica, и много други.

Той се радва на отдалечен начин на живот с най-красивата жена в света.