TOП-12 JavaScript-концепций: от ссылок до асинхронных операций

12 JavaScript-концепций, в которых должен разбираться каждый уважающий себя разработчик. Знать – необходимо, повторять – полезно.

1. Значения и ссылки

Не разобравшись, как язык JavaScript работает с переменными, вы не сможете избежать багов в ваших программах.

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

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

let variable1 = 'My string';
let variable2 = variable1;

В переменной variable2 теперь лежит примитивное строковое значение 'My string', полностью скопированное с variable1. Эти переменные равны, но независимы друг от друга. Изменение variable2 никак не повлияет на variable1.

variable2 = 'My new string';
console.log(variable1); // My string
console.log(variable2); // My new string

А что там с объектами?

let variable1 = { name: 'Jim' }
let variable2 = variable1;

Сейчас в variable2 лежит объект, очень похожий на variable1. На самом деле это один и тот же объект, что очень просто проверить.

variable2.name = 'John';
console.log(variable1); // { name: 'John' }
console.log(variable2); // { name: 'John' }

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

Хотите глубже изучить JavaScript основы языка? Тогда держите гайд по базовым концепциям и руководство для джуна:

2. Замыкания

Замыкание – это одна из важнейших JavaScript-концепций, которая позволяет запретить доступ извне к переменным и функциям.

Из функции createGreeter возвращается анонимная функция, которая всегда будет иметь доступ к параметру greeting. Но никто кроме нее уже не сможет к нему обратиться.

function createGreeter(greeting) {
  return function(name) {
    console.log(greeting + ', ' + name);
  }
}

const sayHello = createGreeter('Hello');
sayHello('Joe'); // Hello, Joe

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

function apiConnect(apiKey) {

  function get(route) {
    return fetch(`${route}?key=${apiKey}`);
  }

  function post(route, params) {
    return fetch(route, {
      method: 'POST',
      body: JSON.stringify(params),
      headers: {
        'Authorization': `Bearer ${apiKey}`
      }
    })
  }

  return { get, post }

}

const api = apiConnect('my-secret-key');
// больше нет необходимости передавать API-ключ,
// он сохранен в замыкании функции api
api.get('http://www.example.com/get-endpoint');
api.post('http://www.example.com/post-endpoint', { name: 'Joe' });

Если замыкания для вас – все еще темный лес, мы подготовили подробный разбор этой концепции:

3. Деструктуризация

Деструктуризация (деструктурирующее присваивание) в JavaScript – это очень крутой способ извлечения данных, упакованных в объекты.

const obj = {
  name: 'Joe',
  food: 'cake'
}

const { name, food } = obj;

console.log(name, food); // 'Joe' 'cake'

Язык JavaScript позволяет даже сохранять свойства в переменных с другими именами.

const obj = {
  name: 'Joe',
  food: 'cake'
}

const { name: myName, food: myFood } = obj;

console.log(myName, myFood); // 'Joe' 'cake'

С помощью деструктурирующего присваивания можно "чисто" передавать параметры в функцию, избегая непредвиденных мутаций. Этот прием используется в React. Возможно, вы с ним уже знакомы:

const person = {
  name: 'Eddie',
  age: 24
}

function introduce({ name, age }) {
  console.log(`I'm ${name} and I'm ${age} years old!`);
}

console.log(introduce(person)); // "I'm Eddie and I'm 24 years old!"

4. Спред-синтаксис

Эта одна из тех JavaScript-концепций, которые часто сбивают с толку, но на самом деле довольно просты.

Например, метод max объекта Math принимает для сравнения несколько аргументов и не умеет работать с массивами. Но спред-оператор легко поправит это дело, ведь он может разделить массив на отдельные элементы:

const arr = [4, 6, -1, 3, 10, 4];
const max = Math.max(...arr);

console.log(max); // 10

А вообще, переходите на Set.

5. Rest-синтаксис

Этот оператор – брат-близнец предыдущего, но с совершенно другим характером. Rest-синтаксис (оставшиеся параметры) собирает несколько значений в один массив:

function myFunc(...args) {
  console.log(args[0] + args[1]);
}

myFunc(1, 2, 3, 4); // 3

6. Методы массивов

Встроенные методы массивов в JavaScript предоставляют удобный и элегантный способ обработки и трансформации данных. И это круто, правда? В языке для них есть огромное множество возможных применений.

Этих супер-методов очень много. Полный список вы найдете на MDN.

map, filter, reduce

Это одни из самых популярных методов работы с массивами, обеспечивающих изменение данных или агрегацию нескольких элементов в одно значение.

map трансформирует все элементы массива с помощью переданной функции. Обработанные элементы собираются в новый массив, который и возвращается из метода.

const mapped = arr.map(el => el + 20);
console.log(mapped); // [21, 22, 23, 24, 25, 26]

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

const arr = [1, 2, 3, 4, 5, 6];
const filtered = arr.filter(el => el === 2 || el === 4);

console.log(filtered); // [2, 4]

reduce же собирает все значения в одно. По какому именно правилу это происходит, определяет коллбэк. Первым параметром он получает аккумулированное из предыдущих элементов значение, а вторым – текущий элемент массива.

const arr = [1, 2, 3, 4, 5, 6];
const reduced = arr.reduce((total, current) => total + current);

console.log(reduced); // 21

find, findIndex, indexOf

Это группа методов поиска.

find по одному перебирает элементы и возвращает первый из них, для которого функция возвращает true. После этого поиск останавливается.

const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const found = arr.find(el => el > 5);

console.log(found); // 6

После элемента 6 поиск останавливается, хотя за ним есть еще несколько подходящих элементов. Это напоминает оператор break, прерывающий цикл. Очень удобный метод, если не нужно перебирать массив целиком.

findIndex – это очень похожий метод, только возвращает он не сам элемент, а его индекс. Для примера возьмем массив строковых значений, чтобы было яснее.

const arr = ['Nick', 'Frank', 'Joe', 'Frank'];
const foundIndex = arr.findIndex(el => el === 'Frank');

console.log(foundIndex); // 1

indexOf – это упрощенный findIndex. Вместо функции он принимает простое значение и сравнивает с ним каждый элемент массива.

const arr = ['Nick', 'Frank', 'Joe', 'Frank'];
const foundIndex = arr.indexOf('Frank');

console.log(foundIndex);
// 1

push, pop, shift, unshift

Удаление и добавление элементов никогда не было таким простым!

push – помещает новый элемент прямо в конец исходного массива, то есть изменяет его на месте. Из метода возвращается длина обновленного массива.

let arr = [1, 2, 3, 4];
const length = arr.push(5);

console.log(arr);
// [1, 2, 3, 4, 5]

console.log(length);
// 5

pop, наоборот, удаляет из исходного массива последний элемент, уменьшая его длину. Этот элемент и является результатом работы метода.

let arr = [1, 2, 3, 4];
const popped = arr.pop();

console.log(arr);
// [1, 2, 3]

console.log(popped);
// 4

shift работает с другой стороны массива – он удаляет первый элемент, а в остальном все то же самое.

let arr = [1, 2, 3, 4];
const shifted = arr.shift();

console.log(arr);
// [2, 3, 4]

console.log(shifted);
// 1

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

let arr = [1, 2, 3, 4];
const unshifted = arr.unshift(5, 6, 7);

console.log(arr);
// [5, 6, 7, 1, 2, 3, 4]

console.log(unshifted);
// 7

Краткое резюме:

  • эти четыре метода изменяют исходный массив на месте;
  • pop иshift возвращают удаленные элементы;
  • push и unshift возвращают новую длину массива;
  • методы shift и unshift, работающие с началом массива, более трудозатратны, так как им приходится сдвигать индексы всех элементов.

splice, slice

Еще пара методов с похожими названиями, в которых легко запутаться. А тем не менее между ними есть существенная разница.

splice – метод-швейцарский нож. Он умеет удалять и добавлять элементы массива, а также заменять уже существующие элементы на другие. Самое главное, он изменяет массив прямо на месте.

let arr = ['a', 'c', 'd', 'e'];
let result = arr.splice(1, 2, 'b1', 'b2', 'b3')

Как работает этот метод?

  • Первый аргумент устанавливает позицию, с которой начинается работа.
  • Второй – число удаляемых элементов.
  • Третий и дальше – элементы, которые нужно добавить.

Таким образом, мы начинаем с позиции 1, удаляем 'c' и 'd' и вставляем сразу три элемента. Метод вернет в переменную result массив из удаленных элементов, а сам исходный массив изменится:

console.log(result); // ['c', 'd']
console.log(arr); // ['a', 'b1', 'b2', 'b3', 'e']

slice, напротив, не меняет массив, а копирует его. Первым аргументом можно указать начальную позицию для копирования, а вторым – конечную. Копия получается неглубокая, то есть все объекты будут переданы в новый массив по ссылке.

let arr = ['a', 'b', 'c', 'd', 'e'];
const sliced = arr.slice(2, 4);

console.log(sliced);
// ['c', 'd']

console.log(arr);
// ['a', 'b', 'c', 'd', 'e']

sort

Несложно догадаться, что делает этот метод – да, он сортирует исходный массив. По умолчанию все элементы преобразуются в строке и сравниваются посимвольно в соответствии с таблицами Unicode.

Обратите внимание, все элементы преобразуются в строки! Включая обычные числа. Это вызывает неожиданные для многих эффекты и порождает "нелогичные" JavaScript примеры, вроде этого:

let arr = [1, 2, 10, 21]
arr.sort(); // [1, 10, 2, 21]

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

let arr = [1, 7, 3, -1, 5, 7, 2];
const sorter = (firstEl, secondEl) => firstEl - secondEl;
arr.sort(sorter);

Сортирующая функция должна вернуть число:

  • если оно будет положительным, первый и второй элемент поменяются местами;
  • в ином случае – порядок не изменится.

Обратите внимание: устойчивость сортировки не гарантируется. Если коллбэк вернет 0, элементы не должны меняться местами, но полагаться на это поведение не следует.

Вот пример более сложной сортировки для массива объектов:

let john = { name: "John", age: 23 };
let jane = { name: "Jane", age: 18 };
let jerry = { name: "Jerry", age: 34 };

let people = [john, jane, jerry];
let sorter = (a, b) => a.age - b.age;

people.sort(sorter);

Методы массивов – прямая демонстрация возможностей функционального программирования. Почитайте об этой концепции подробнее:

7. Генераторы

Прошло уже немало времени с момента появления в JavaScript функций-генераторов, однако многие разработчики все еще с опаской поглядывают на эту непонятную звездочку (астериск) *.

Генератор – это просто функция, которую можно остановить в любой момент, чтобы получить промежуточное значение. Для этого есть метод next().

Генератор может быть конечным. В определенный момент промежуточные точки кончатся, и работа функции будет остановлена, а вызов next вернет undefined.

function* greeter() {
  yield 'Hi';
  yield 'How are you?';
  yield 'Bye';
}

const greet = greeter();

console.log(greet.next().value);
// 'Hi'

console.log(greet.next().value);
// 'How are you?'

console.log(greet.next().value);
// 'Bye'

console.log(greet.next().value);
// undefined

Но никто не мешает зациклить функцию для бесконечной генерации значений:

function* idCreator() {
  let i = 0;
  while (true)
    yield i++;
}

const ids = idCreator();

console.log(ids.next().value);
// 0

console.log(ids.next().value);
// 1

console.log(ids.next().value);
// 2
// etc...

Обратите внимание: первый вызов функции только создает объект-генератор с методом next(), но не возвращает никакого значения из самой функции.

Генераторы тесно связаны с другими нововведениями стандарта ES6 – символами и итераторами. Держите подробное руководство для всех этих JavaScript-концепций сразу:

8. Обычное равенство (==) против строгого (===)

Вы точно понимаете, в чем разница между двумя этими операторами? Если нет, ждите ошибок.

На самом деле, все очень просто.

  • Оператор === при сравнении учитывает тип операндов. Строка у него никогда не будет равна числу, даже если выглядят они очень похоже.
  • Оператор == по возможности пытается привести операнды к одному типу. Он допускает, что число 1 для вас означает ровно то же самое, что и строка "1".
console.log(0 == '0'); // true
console.log(0 === '0'); // false

Это одна из мощных и удобных JavaScript-концепций, если, конечно, вы понимаете, что делаете. Берите на вооружение. Оператор == освобождает от ручного преобразования типов в тех ситуациях, когда тип не имеет значения. Но убедитесь, что это действительно так.

9. Сравнение объектов

Начинающие JavaScript-программисты очень часто пытаются сравнивать объекты напрямую. Они ожидают, что объекты с идентичными свойствами и одинаковыми их значениями будут равны – и ловят уйму ошибок.

Возвращаемся к самой первой из перечисленных в этой статье JavaScrpipt-концепций. В переменной не содержится физическое воплощение конкретного объекта, а только ссылка на место в памяти, где он хранится. Значит, сравнивая две переменные с объектами, вы сравниваете ссылки! А разные объекты, даже если они абсолютно одинаковы, всегда хранятся в разных местах памяти.

const joe1 = { name: 'Joe' };
const joe2 = { name: 'Joe' };
const joe3 = joe1;

console.log(joe1 === joe2); // false
console.log(joe1 === joe3); // true

Для "глубокого" сравнения, то есть перебора свойств и сравнения их значений, можно использовать JSON-сериализацию. Объекты преобразуются в строки, которые можно сравнивать напрямую. Но при этом не гарантируется сохранение порядка свойств, а значит, результат может быть ошибочным.

JavaScript фреймворки и библиотеки обычно уже имеют специальные методы для "глубокого" сравнения, например, в lodash есть метод  isEqual.

10. Функции обратного вызова

Если слово "коллбэк" вызывает у вас приступ паники, значит, вы просто не умеете его готовить. Функции обратного вызова очень просты, в 6 пункте мы вдоволь с ними наработались.

Коллбэк – это функция, которая передается в другую функцию в качестве аргумента. Эта вторая функция будет делать свои дела, а потом вызовет коллбэк, сигнализируя, что ее работа закончена. Это весьма упрощенное описание, но тем не менее коллбэки до ES6 были одной из важнейших JavaScript-концепций асинхронного программирования.

function myFunc(text, callback) {
  setTimeout(function() {
    callback(text);
  }, 2000);
}

myFunc('Hello world!', console.log); // 'Hello world!'

Здесь мы используем функцию обратного вызова, чтобы узнать, когда закончится таймаут, и что-то после этого сделать.

11. Промисы

Да-да, коллбэки не всегда удобны, они громоздки и с трудом выстраиваются в цепочку. Поэтому мы и пришли к промисам – удобному воплощению асинхронности.

Впрочем, callback-концепция и тут никуда не уходит, однако становится намного удобнее.

const myPromise = new Promise(function(res, rej) {

  setTimeout(function(){
    if (Math.random() < 0.9) {
      return res('Hooray!');
    }
    return rej('Oh no!');
  }, 1000);

});

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

Сам по себе промис (обещание) – это просто объект, имеющий определенное состояние:

  • ожидание – функция, переданная в конструктор, только начала выполняться;
  • выполнено – операция закончилась успешно;
  • отклонено – возникла какая-либо ошибка.

Если все прошло хорошо, и вызвана функция res, выполняется обработчик, переданный в метод промиса then. А если выпала ошибка управление перейдет в обработчик из catch.

myPromise
  .then(function(data) {
    console.log('Success: ' + data);
  })
  .catch(function(err) {
    console.log('Error: ' + err);
  });

// Если Math.random() вернул значение меньше 0.9 считаем, что функция выполнена удачно
// "Success: Hooray!"

// Иначе имитируем ошибку
// "Error: On no!"

Асинхронное программирование – не самая простая из JavaScript-концепций. Если у вас есть желание изучить ее немного глубже, загляните сюда:

12. Async Await

Еще один шаг вверх по лестнице удобной асинхронности. Концепция async/await – просто синтаксический сахар над промисами, но разработку она поднимает на новый уровень.

const greeter = new Promise((res, rej) => {
  setTimeout(() => res('Hello world!'), 2000);
})

async function myFunc() {
  const greeting = await greeter;
  console.log(greeting);
}

myFunc(); // 'Hello world!'

В чем магия асинхронной функции myFunc? В том, что она самостоятельно дождется выполнения промиса greeter и только после этого продолжит выполнение. Оператор await остановит поток выполнения этой функции до получения результата.

Более подробный разбор этих интереснейших JavaScript-концепций вы найдете здесь:

12 JavaScript-концепций

На этих 12 концепциях стоит современный JavaScript – удобный и мощный язык программирования. Понимание каждой из них критично для хорошего разработчика.

Оригинал: 12 Concepts That Will Level Up Your JavaScript Skills

А по-вашему, на каких китах стоит JS? Давайте обсудим JavaScript основы и концепции в комментариях.

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

eFusion
01 марта 2020

ТОП-15 книг по JavaScript: от новичка до профессионала

В этом посте мы собрали переведённые на русский язык книги по JavaScript – ...
admin
10 июня 2018

Лайфхак: в какой последовательности изучать JavaScript

Огромный инструментарий JS и тонны материалов по нему. С чего начать? Расск...