Источник: Nuances of Programming
Введение
Обсудим некоторые темы 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 можно выполнить простое преобразование и использовать его несколько раз. Для небольшого массива потребление памяти незначительно, но в случае больших массивов влияние на память будет очень заметным.
Так какие же решения наиболее эффективны в этом случае?
Прежде всего, вам нужно понять, что обработка больших массивов превышает сложность пространства. Затем подумайте, как можно уменьшить потребление памяти. В нашем случае есть несколько продуктивных решений.
- Соединить 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.
Читайте также:
Перевод статьи Ohad Koren: 5 Essential Points You Should Be Familiar With as a JavaScript Developer