5 важных моментов из JavaScript, которые помогут избегать ошибок

Введение

Обсудим некоторые темы JavaScript, которые, на мой взгляд, должен знать каждый разработчик.

При работе с JavaScript всегда следует стремиться по возможности улучшить качество кода. Исходя из личного опыта, я выделил 5 тем, понимание которых помогает разработчикам избегать ошибок и принимать правильные решения в отношении кода.

#1. Promise.all() и Promise.allSettled()

Работа с промисами  —  неотъемлемая часть написания JavaScript-кода.

Есть много способов обращения с ними, но важно подумать о том, что подходит именно вам.

Promise.all()

Этот метод принимает итерируемый промис в качестве входного параметра и возвращает один промис. Результат каждого входного промиса будет преобразован в массив комплексных результатов.

Рассмотрим пример ниже:

const promise1 = Promise.resolve(555);
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'foo'));
const promise3 = 23;

const allPromises = [promise1, promise2, promise3];
Promise.all(allPromises).then(values => console.log(values));

// Вывод: [ 555, 'foo', 23 ]

Если разрешаются все три промиса, разрешается и Promise.all(), и значения будут выведены.

Но что, если один промис (или несколько) не разрешится и будет отклонен?

const promise1 = Promise.resolve(555);
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'foo'));
const promise3 = Promise.reject('I just got rejected!');

const allPromises = [promise1, promise2, promise3];

Promise.all(allPromises)
.then(values => console.log(values))
.catch(err => console.error(err));

// Вывод: Отклонено!

Promise.all() отклоняется, если хотя бы один из элементов отклонен.

Как следует из приведенного выше примера, если 2 передаваемых промиса разрешаются, а один немедленно отклоняется, то Promise.all() будет немедленно отклонен.

Promise.allSettled()

Этот метод был введен в ES2020. Promise.allSettled() принимает в качестве входного параметра итерируемый промис, но в отличие от Promise.all(), возвращает промис, который всегда разрешается после того, как все заданные промисы либо выполняются, либо отклоняются. Промис разрешается с помощью массива объектов, описывающих результат выполнения каждого промиса.

Для каждого результата выполнения промиса получаем одно из двух:

  • статус fulfilled (выполнен) со значением результата;
  • статус rejected (отклонен) с указанием причины отклонения.

Рассмотрим подробнее:

const promise1 = Promise.resolve(555);
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'foo'));
const promise3 = Promise.reject('I just got rejected!');

const allPromises = [promise1, promise2, promise3];

Promise.allSettled(allPromises)
.then(values => console.log(values))

// Вывод:
// [
// { статус: 'fulfilled', значение: 555 },
// { статус: 'fulfilled', значение: 'foo' },
// { статус: 'rejected', причина: 'Октлонено!' }
// ]

Какой из них выбрать?

Если вы хотите, чтобы система быстро прекращала работу в случае ошибки, следует выбрать Promise.all().

Рассмотрим сценарий, в котором необходимо, чтобы все запросы были fulfilled (выполнены), и определим некоторую логику, основанную на этом успехе. В этом случае быстрое прекращение работы в случае ошибки вполне подходит, поскольку после отклонения одного из запросов остальные вызовы становятся больше не актуальными. Нет необходимости тратить ресурсы на оставшиеся вызовы.

Однако в других случаях может понадобиться, чтобы все вызовы были либо rejected (отклонены), либо fulfilled (выполнены). Если полученные данные используются для отдельной последующей задачи, или вы хотите отобразить и получить доступ к информации об ошибках каждого вызова, лучше выбрать Promise.allSettled().

#2. Оператор нулевого слияния (??)

Оператор нулевого слияния обозначается двумя вопросительными знаками ??. Этот оператор возвращает правосторонний операнд, если его левосторонний операнд равен нулю или не определен. В противном случае возвращает левосторонний операнд.

Это легко понять на простом примере. Результатом x ?? y будет:

  • x, если значение x не является ни null, ни undefined;
  • y, если значение x является null или undefined. 

Оператор нулевого слияния применяется нечасто, особенно в среде новых разработчиков Javascript. Это просто красивый синтаксис для получения первого значения defined двух переменных.

Вы можете написать x ?? y следующим образом:

result = (x !== null && x !== undefined) ? x : y;

Теперь вам должно быть понятно, что делает ??.

Обычный случай использования ??  —  это предоставление значения по умолчанию. Например, здесь мы отображаем name, если значение не является null/undefined, иначе  —  Unknown:

let name;
alert(name ?? "Unknown"); // Вывод: Unknown (name является undefined)

Вот пример, в котором name присваивается левому операнду:

let name = "Michael";
alert(name ?? "Unknown"); // Michael (name не является ни null, ни undefined)

Сравнение с оператором OR “||”

Оператор OR || можно использовать так же, как и ??.

Можно заменить ?? на || и получить тот же результат, например:

let name;
alert(name ?? "Unknown"); // Вывод: Unknown
alert(name || "Unknown"); // Вывод: Unknown

Оператор OR || существует с самого начала развития JavaScript, поэтому разработчики давно используют его для этих целей. Оператор ?? добавлен в JavaScript совсем недавно (ES2020), так как члены сообщества были не совсем довольны оператором ||.

Важное различие между ними заключается в следующем:

  • || возвращает первое истинное значение;
  • ?? возвращает первое значение defined (defined = не null и не undefined).

Другими словами, || не делает различий между false, 0, пустой строкой "" и null/undefined. Все это  —  ложные значения. Если любое из них является первым аргументом ||, то в качестве результата вы получите второй аргумент. Например:

let grade = 0;
alert(grade || 100); // Вывод: 100
alert(grade ?? 100); // Вывод: 0

grade || 100 проверяет, не является ли значение grade ложным, а значение grade равно 0, что является ложным значением. Поэтому результатом || будет второй аргумент  —  100. grade ?? 100 проверяет, не является ли значение grade null или undefined, но это не так, поэтому результатом grade остается 0.

#3. Неправильное использование “this”

this ​— часто неправильно интерпретируемое понятие в JavaScript. Чтобы применять this​ в JavaScript, нужно понимать, как оно работает, потому что оно функционирует немного иначе, чем в других языках.

Вот пример распространенной ошибки при использовании this​:

const obj = {
helloWorld: "Hello World!",
printHelloWorld: function () {
console.log(this.helloWorld);
},
printHelloWorldAfter1Sec: function () {
setTimeout(function () {
console.log(this.helloWorld);
}, 1000);
},
};

obj.printHelloWorld();
// Вывод: Hello World!

obj.printHelloWorldAfter1Sec();
// Вывод: undefined

Первый результат  —  Hello World!, потому что this.helloWorld​ правильно указывает на свойство name объекта. Второй результат  —  undefined​, потому что в this​ потеряна ссылка на свойства объекта.

Это происходит потому, что this​ зависит от объекта, вызывающего функцию, в которой он находится. Переменная this​ есть в каждой функции, но объект, на который она указывает, определяется вызывающим ее объектом.

This​ в obj.printHelloWorld()​ указывает непосредственно на obj​. This​ в obj.printHelloWorldAfter1Sec()​ указывает непосредственно на obj​. Но this​ в функции обратного вызова setTimeout​ не указывает ни на какой объект, потому что ни один объект не вызывается. Используется объект по умолчанию (которым является window​).​​ name​ ​ не существует для window​, что приводит к значению undefined.

Как это исправить?

Лучший способ сохранить ссылку на this​ в setTimeout  —  использовать стрелочные функции (которые были введены в ES6). В отличие от обычных функций, стрелочные функции не создают собственное this​.

Поэтому в следующем примере ссылка на this​ сохранится.

const obj = {
helloWorld: "Hello World!",
printHelloWorld: function () {
console.log(this.helloWorld);
},
printHelloWorldAfter1Sec: function () {
setTimeout(() => {
console.log(this.helloWorld);
}, 1000);
},
};

obj.printHelloWorld();
// Вывод: Hello World!

obj.printHelloWorldAfter1Sec();
// Вывод: Hello World!

Вместо стрелочной функции, можно применить другие подходы для достижения решения. Коротко поясню суть каждого из них.

  • Метод bind(). Создает новую функцию с заданным значением this и возвращает его. Вы можете использовать этот метод для привязки функции к определенному объекту, и this всегда будет ссылаться на этот объект.
  • Методы call() и apply(). Позволяют вызвать функцию с определенным значением this. Разница между ними заключается в том, что call() принимает аргументы в виде списка значений, а apply()  —  в виде массива.
  • Переменная self. Распространенный подход, который использовался до появления стрелочных функций. Идея заключается в том, чтобы хранить ссылку на this в переменной и использовать эту переменную внутри функции. Обратите внимание на то, что этот подход может давать сбой при работе с вложенными функциями.

В целом, каждый из перечисленных подходов имеет свои преимущества и недостатки, и выбор зависит от конкретной ситуации. Для большинства случаев в качестве выбора по умолчанию я все же рекомендую использовать стрелочную функцию.

#4. Чрезмерное использование памяти

Эту проблему легко распознать во время выполнения, когда вы наблюдаете за экземплярами сервера и видите, что среднее потребление памяти увеличивается. Первое, что может прийти в голову,  —  это увеличить лимит памяти по умолчанию в Node JS.

Но сначала стоит спросить себя: “Почему потребление памяти такое высокое?”.

На этот вопрос может быть много ответов. Рассмотрю только один распространенный случай, чтобы привлечь ваше внимание к этой проблеме.

Разберем следующий пример.

Допустим, у нас есть следующие данные:

const data = [
{ name: 'Frogi', type: Type.Frog },
{ name: 'Mark', type: Type.Human },
{ name: 'John', type: Type.Human },
{ name: 'Rexi', type: Type.Dog }
];

Нам нужно добавить некоторые свойства для каждой сущности, в зависимости от ее type:

const mappedArr = data.map((entity) => {
return {
...entity,
walkingOnTwoLegs: entity.type === Type.Human
}
});
// ...
// другой код
// ...
const tooManyTimesMappedArr = mappedArr.map((entity) => {
return {
...entity,
greeting: entity.type === Type.Human ? 'hello' : 'none'
}
});

console.log(tooManyTimesMappedArr);
// Вывод:
// [
// { name: 'Frogi', type: 'frog', walkingOnTwoLegs: false, greeting: 'none' },
// { name: 'Mark', type: 'human', walkingOnTwoLegs: true, greeting: 'hello' },
// { name: 'John', type: 'human', walkingOnTwoLegs: true, greeting: 'hello' },
// { name: 'Rexi', type: 'dog', walkingOnTwoLegs: false, greeting: 'none' }
// ]

Здесь показано, как с помощью map можно выполнить простое преобразование и использовать его несколько раз. Для небольшого массива потребление памяти незначительно, но в случае больших массивов влияние на память будет очень заметным.

Так какие же решения наиболее эффективны в этом случае?

Прежде всего, вам нужно понять, что обработка больших массивов превышает сложность пространства. Затем подумайте, как можно уменьшить потребление памяти. В нашем случае есть несколько продуктивных решений.

  1. Соединить maps в цепочку, избегая многократного клонирования:
const mappedArr = data
.map((entity) => {
return {
...entity,
walkingOnTwoLegs: entity.type === Type.Human
}
})
.map((entity) => {
return {
...entity,
greeting: entity.type === Type.Human ? 'hello' : 'none'
}
});

console.log(mappedArr);
// Вывод:
// [
// { name: 'Frogi', type: 'frog', walkingOnTwoLegs: false, greeting: 'none' },
// { name: 'Mark', type: 'human', walkingOnTwoLegs: true, greeting: 'hello' },
// { name: 'John', type: 'human', walkingOnTwoLegs: true, greeting: 'hello' },
// { name: 'Rexi', type: 'dog', walkingOnTwoLegs: false, greeting: 'none' }
// ]

2. Еще лучше было бы уменьшить количество map и операций клонирования:

const mappedArr = data.map((entity) => 
entity.type === Type.Human ? {
...entity,
walkingOnTwoLegs: true,
greeting: 'hello'
} : {
...entity,
walkingOnTwoLegs: false,
greeting: 'none'
}
);

console.log(mappedArr);
// Вывод:
// [
// { name: 'Frogi', type: 'frog', walkingOnTwoLegs: false, greeting: 'none' },
// { name: 'Mark', type: 'human', walkingOnTwoLegs: true, greeting: 'hello' },
// { name: 'John', type: 'human', walkingOnTwoLegs: true, greeting: 'hello' },
// { name: 'Rexi', type: 'dog', walkingOnTwoLegs: false, greeting: 'none' }
// ]

#5. Map/объектный литерал вместо оператора switch

Нам нужно вывести города в зависимости от стран, в которых они находятся. Посмотрим на пример ниже:

function findCities(country) {
// Используйте switch, чтобы найти города по странам
switch (country) {
case 'Russia':
return ['Moscow', 'Saint Petersburg'];
case 'Mexico':
return ['Cancun', 'Mexico City'];
case 'Germany':
return ['Munich', 'Berlin'];
default:
return [];
}
}

console.log(findCities(null)); // Вывод: []
console.log(findCities('Germany')); // Вывод: ['Munich', 'Berlin']

Кажется, что в приведенном выше коде нет ничего плохого, но по моему мнению, он выполнен в стиле довольно “жесткого” программирования. Того же результата можно добиться с помощью объектного литерала с более чистым синтаксисом:

// Используйте объектный литерал, чтобы найти города по странам 
const citiesCountry = {
Russia: ['Moscow', 'Saint Petersburg'],
Mexico: ['Cancun', 'Mexico City'],
Germany: ['Munich', 'Berlin']
};

function findCities(country) {
return citiesCountry[country] ?? [];
}

console.log(findCities(null)); // Вывод: []
console.log(findCities('Germany')); // Вывод: ['Munich', 'Berlin']

​Map  —  это объектный тип, введенный в ES6, который позволяет хранить пары ключ-значение. Для достижения того же результата можно использовать ​Map:

// Используйте Map ,чтобы найти города по странам 
const citiesCountry = new Map()
.set('Russia', ['Moscow', 'Saint Petersburg'])
.set('Mexico', ['Cancun', 'Mexico City'])
.set('Germany', ['Munich', 'Berlin']);

function findCities(country) {
return citiesCountry.get(country) ?? [];
}

console.log(findCities(null)); // Вывод: []
console.log(findCities('Germany')); // Вывод: ['Munich', 'Berlin']

Значит ли это, что нужно перестать использовать оператор ​switch? Я этого не утверждаю. Лично я считаю, что Map и объектные литералы повышают уровень кода и делает его более элегантным (если можно их использовать).

Основные различия между Map и объектным литералом:

  • Ключи. В ​Map ключи могут быть любого типа данных (включая объекты и примитивы). В объектном литерале ключи должны быть строками или символами.
  • Итерация. В ​Map можно легко перебирать записи с помощью цикла ​for…of или метода ​forEach(). В объектном литерале для итерации по ключам, значениям или записям необходимо использовать Object.keys(), ​Object.values() или Object.entries().
  • Производительность. В целом, ​Map работает лучше, чем объектные литералы, когда речь идет о больших наборах данных или частых добавлениях/удалениях. Однако в случае небольших наборов данных или нечастых операций разница в производительности незначительна.

Выбор типа структуры данных зависит от конкретного случая использования, но и Map, и объектные литералы являются полезными структурами данных.

Заключение

В этой статье я попытался охватить некоторые важные вопросы, которые должны быть полезны как начинающим, так и опытным разработчикам JavaScript.

Конечно, существует гораздо больше идей, чем перечислено выше. Поэтому старайтесь быть в курсе инструментов и лучших практик разработки JavaScript.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Ohad Koren: 5 Essential Points You Should Be Familiar With as a JavaScript Developer

Предыдущая статьяНастройка сервера AWS Aurora PostgreSQL и мониторинг его производительности
Следующая статьяЗагрузочные представления в SwiftUI