Фото предоставлено Маркусом Списком
Фото предоставлено Маркусом Списком

Вступление

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

В этой статье я буду объяснять некоторые из продвинутых концепций JavaScript, которые должны быть известны каждому опытному разработчику. Я начну с замыканий — мощного способа создавать приватные переменные и функции в JavaScript. Затем, я подробно разберу ключевое слово this.

Далее, я погружусь в прототипное наследование — ключевую особенность JavaScript разрешающую объектом наследовать свойства и методы от других объектов. Также я объясню концепцию асинхронного программирования, которая является необходимой для создания масштабируемых и производительных веб‑приложений.

После, я затрону тему поднятия(hoisting), цикла событий(event loop) и приведения типов(type coercion).

Напоследок, я расскажу про деструктуризацию — еще одну мощную особенность JavaScript, которая позволяет извлекать значения из массивов и объектов лаконичным и легко читаемым способом.

Замыкания

Замыкания — это одни из самых фундаментальных и мощных концепций в JavaScript. В двух словах, замыкание — это функция, которая сохраняет переменные и функции, которые были в ее области видимости в момент создания, даже если те переменные и функции уже больше не находятся в ее области видимости. Таким образом, функции разрешено обращаться и манипулировать этими переменными и функциями даже если они находятся в другой области видимости.

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

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

Этот простой пример должен проиллюстрировать работу замыканий:

function outer() {
  const name = "Paul";
  function inner() {
    console.log("Hello, " + name + "! Welcome to paulsblog.dev");
  }
  return inner;
}

const sayHello = outer();
sayHello(); // logs "Hello, Paul! Welcome to paulsblog.dev"

В этом примере outer() объявляет переменную name и внутреннюю функцию inner(), которая выводит сообщение используя внешнюю переменную name. Затем, outer() возвращает inner.

Теперь, вызывая outer() и присваивая ее возвращенное значение переменной sayHello, замыкание создано, оно включает в себя функцию inner() и переменную name. Это замыкание сохраняет доступ к переменной name даже после того, как функция outer() завершила свое выполнение, позволяя вызвать sayHello для вывода приветственного сообщения.

Замыкания широко используются в JavaScript для многих целей, таких как создание частных переменных и функций, реализовывание функций обратного вызова(callbacks) и управление асинхронным кодом.

This

В JavaScript this является специальным ключевым словом, которое относится к объекту выполняющему функцию или метод. Значение this зависит от того, как функция вызывается, и определяется динамически во время выполнения. Также значение this может быть разным внутри одной и той же функции, в зависимости от того как эта функция была вызвана.

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

Рассмотрим следующий код:

let person = {
  firstName: "Paul",
  lastName: "Knulst",
  fullName: function() {
    return this.firstName + " " + this.lastName;
  }
};

console.log(person.fullName()); // Output: "Paul Knulst"

В этом примере this ссылается на объект person. Когда вызывается person.fullName(), то this ссылается на person, таким образом this.firstName и this.lastName ссылаются на свойства данного объекта.

Теперь давайте рассмотрим следующий код:

function myFunction() {
  console.log(this);
}

myFunction(); // Output: Window object

В данном примере this ссылается на глобальный объект(объект window в браузере). При вызове функции по умолчанию используется глобальный объект.

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

let obj = {
  myFunction: function() {
    console.log(this);
  }
}

obj.myFunction(); // Output: {myFunction: ƒ}

let func = obj.myFunction
func(); // Output: Window object

В этом примере объект obj содержит метод myFunction, который выводит в консоль this.

Вызывая метод myFunction через точечную запись(obj.myFunction()) this будет ссылаться на сам объект obj. Таким образом, результат будет {myFunction: f}.

Присваивая метод myFunction к новой переменной func с ее последующим вызывом this ссылается на объект Window, потому что func вызывается без состояния объекта и this будет ссылаться на глобальный объект(объект Window в браузере).

В JavaScript this является важным ключевым словом. Каждый разработчик должен полностью понимать его функциональность. После того, как вы ознакомились со всем перечисленным в данном разделе, вам следует прочитать о методах bind и call, которые используются для управления this в JavaScript.

Наследование прототипов

Наследование прототипов — это механизм доступный объектам в JavaScript для наследования свойств и методов от других объектов. Каждый объект в JavaScript имеет прототип, от которого наследуются свойства и методы.

Например:

  • Объекты Date наследуются от Date.prototype

  • Объекты Array наследуются от Array.prototype

Если у объекта нет какого-либо свойства или метода, то JavaScript просматривает цепочку прототипов до тех пор, пока не найдет нужное свойство или метод в прототипе объекта: Object.prototype

Рассмотрим следующий пример:

function Person(name, website) {
  this.name = name;
  this.website = website;
}

Person.prototype.sayHello = function() {
  console.log(`Hello, my name is ${this.name} and this is my website: ${this.website}`);
};

const paul = new Person("Paul", "https://www.paulsblog.dev);

paul.sayHello(); // Output: Hello, my name is Paul and this is my website: https://www.paulsblog.dev

В этом примере создана функция-конструктор Person и ее прототипу добавлен метод через свойство prototype. Далее, создается новый объект Person и вызывается функция sayHello(), которая не объявлена в самом объекте, но так как JavaScript просматривает цепочку прототипов, то он ее найдет.

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

Асинхронное программирование

Асинхронное программирование — это паттерн программирования, который позволяет множеству задач обрабатываться параллельно без блокировки друг‑друга или основного потока. В языке JavaScript это чаще всего достигается применением обратных вызовов(сallbacks), обещаний(promises) и ключевых слов async/await.

Обратные вызовы(callbacks)

Обратные вызовы(callbacks, коллбэки) — это функции, которые передаются другим функциям в качестве аргументов и выполняются, когда произойдет определенное событие. Например, функция setTimeout принимает функцию первым аргументом и выполняет ее после определенного числа миллисекунд.

// Use setTimeout to execute a callback after 1 second
setTimeout(function() {
  console.log("Hello, world!");
}, 1000);

Чтобы узнать больше о том, как взаимодействовать с коллбэками в JavaScript прочитайте это руководство объясняющее лучшие практики .

Обещания(Promises)

В противоположность коллбэкам, обещания(промисы) — это объекты, представляющие значение, которое на данный момент может быть не доступно, например результат асинхронной операции. Также у них есть метод then, который принимает коллбэк‑функцию в качестве аргумента и вызывает ее после того, как промис выполнится со значением:

// Use a promise to get the result of an asynchronous operation
const fetchData = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Data received");
    }, 1000);
  });
};

// Call the fetchData function and handle the promise result
fetchData().then(result => {
  console.log(result);
});

В этом примере fetchData() объявлена в качестве функции возвращающей объект промис содержащий вызов функции setTimeout для симуляции асинхронной операции, которая разрешается строкой(«Data received»).

При вызове функции fetchData() будет возвращен промис, который может быть обработан с помощью метода then(). Метод then(), который будет выполнен после того, как завершится асинхронный вызов, принимает коллбэк‑функцию выводящую result в консоль.

Async/Await

Async/Await — это недавнее дополнение к языку JavaScript, предоставляющее простой, новый синтаксис для работы с асинхронными функциями возвращающими промисы. С помощью него вы можете писать асинхронный код, который больше похож на синхронный используя ключевые слова async и await.

Взгляните на следующий пример:

const fetchData = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Data received");
    }, 1000);
  });
};

const getData = async () => {
  try {
    const result = await fetchData();
    console.log(result);
  } catch (error) {
    console.error(error);
  }
};

getData();

В этом примере объявлена функция fetchData(), которая возвращает промис и разрешается через одну секунду возвращая строку «Data received». Затем объявляется функция getData(), которая использует специальное слово async, что бы отметить, что она является асинхронной. Эта функция будет обрабатывать ранее объявленую функцию fetchData() используя специальное слово await и присваивать ею возвращенное значение к переменной result. Затем result выводится в консоль.

Используя синтаксис async/await в JavaScript мы можем писать асинхронный код в синхронной манере, делая его легче для чтения и понимания, так как в нем избегаются вложенные коллбеки или цепочки .then в результате чего возникает так называемый «ад коллбеков или промисов».

Цикл событий(Event Loop)

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

Чтобы избежать блокировки выполнения кода из‑за длительных операций JavaScript предоставляет механизм названный «циклом событий»(event loop), который беспрерывно отслеживает стек вызовов(call stack) и структуры данных очереди сообщений. В то время как стек вызовов знает о тех функциях, которые выполняются в текущий момент времени, очередь сообщений содержит список сообщений(событий) и связанных с ними коллбек‑функций.

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

Данный механизм цикла событий позволяет JavaScript'у управлять асинхронными событиями без блокировки программы.

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

Рассмотрим следующий пример:

console.log('start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise');
});

console.log('end');

Данный пример содержит 4-е разные функции для вывода сообщения.

  1. console.log('start'): простой вывод сообщения;

  2. setTimeout(() => { ... }, 0): вывод сообщения с использованием функции setTimeout с 0-ой отсрочкой;

  3. Promise.reslve().then(() => { ... }): вывод сообщения с использованием мгновенно разрешенного промиса;

  4. console.log('end'): простой вывод сообщения.

Вывод программы будем следующим:

start
end
Promise
setTimeout

Это может показаться нелогичным, так как таймер внутри setTimeout установлен на 0мс, и вы возможно ожидаете, что эта функция будет вызвана сразу же или сразу же после синхронных вызовов с выводом сообщений 'start' и 'end'. Причина, по которой промис выводится до setTimeout, кроется в работе цикла событий в JavaScript и в том, что очередь сообщений может быть в дальнейшем разделена на макрозадачи(setTimeout, setInterval) и микрозадачи(промисы, async/await).

Цикл событий выполняет данную программу в следующем порядке:

  1. Выполнится выражение console.log('start') и строка 'start' выведется в консоль.

  2. Вызовется функция setTimeout с коллбеком, который выведет строку 'setTimeout'. Однако не смотря на то, что таймер установлен на 0мс, коллбек не вызовется прямо сейчас. Вместо этого он будет добавлен в очередь событий.

  3. Выполнится выражение Promise.resolve().then(), что создаст новый промис, который тут же выполнится. Затем вызовется метод then с коллбеком, который выведет строку 'Promise' в консоль. Этот коллбек добавится в очередь микрозадач.

  4. Выполнится выражение console.log('end') и строка 'end' выведется в консоль.

  5. Так как стек вызовов на данный момент пуст, JavaScript проверяет очередь микрозадач(часть цикла событий) на предмет задач, которые должны быть выполнены и находит коллбек метода then из промиса. Он будет выполнен с выводом в консоль сообщения 'Promise'.

  6. После того, как очередь микрозадач опустела, JavaScript проверяет очередь событий на другие задачи, которые должны быть выполнены и находит коллбек из функции setTimeout, и выполняет его с выводом в консоль сообщения 'setTimeout'.

Цикл событий позволяет программе управлять асинхронными событиями при этом не блокируя поток выполнения задач, таким образом другой код может быть выполнен во время ожидания завершения выполнения асинхронных операций. Также помните, что есть немного неинтуитивное выполнение программы при работе с setTimeout/промисами.

Поднятие(Hoisting)

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

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

Рассмотрим следующий пример кода показывающий поднятие переменной:

console.log(x); // Output: undefined
var x = 10;

В этом примере переменная x будет выведена в консоль до ее объявления. Код выполняется без каких-либо ошибок и выводит в консоль undefined потому, что только объявление переменной поднимается наверх области видимости, а не ее присвоение.

Следующий пример покажет поднятие функции:

myFunction(); // Output: "Hello World"
function myFunction() {
  console.log("Hello World");
}

В этом примере функция myFunction вызывается до ее объявления. Так как объявление функции поднимается наверх ее области видимости, код работает как и ожидается, и выводит "Hello World" в консоль.

Этот код покажет поднятие функционального выражения:

myFunction(); // Output: TypeError: myFunction is not a function
var myFunction = function() {
  console.log("Hello World");
};

Здесь функция вызывается до того, как она обозначается. Так как функциональное выражение объявлено и присвоено переменной myFunction, то оно не поднимается также как объявление функции. Когда мы попытаемся вызвать myFunction, то результатом станет TypeError.

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

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

Приведение типов(type coercion)

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

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

В этом примере показаны самые распространенные приведения типов в JavaScript:

// Example 1
console.log(5 + "5"); // Output: "55"

// Example 2
console.log("5" * 2); // Output: 10

// Example 3
console.log(false == 0); // Output: true

// Example 4
console.log(null == undefined); // Output: true

Пример 1: Объединение строки и числа используя +. В JavaScript использование + со строкой автоматически трансформирует другое значение также в строку.

Пример 2: Умножение строки и числа используя *. При использовании * строка будет приведена к числу.

Пример 3: Сравнение логического значения с числом используя ==. JavaScript приводит логическое значение к числу(false - это 0), а затем производит сравнение с выводом значения true, так как 0 == 0.

Пример 4: Сравнение значения null и undefined используя ==. Так как JavaScript автоматически приводит null к undefined, то в результате будет сравнение undefined == undefined, что даст значение true.

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

Интересный факт: Существует эзотерический и образовательный стиль программирования основанный на странном преобразовании типов в JavaScript, который использует только 6 разных знаков для написания и выполнения кода JavaScript. Используя данный подход разобранный на этом сайте следующий код выполнится в любой консоли JavaScript и результат будет таким же как выполнение alert(1):

[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]][([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[+!+[]+[!+[]+!+[]+!+[]]]+[+!+[]]+([+[]]+![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[!+[]+!+[]+[+[]]])()

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

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

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

Деструктуризация массивов

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

А вот и пример:

const numbers = [1, 2, 3, 4, 5];

const [first, second, ...rest] = numbers;

console.log(first); // Output: 1
console.log(second); // Output: 2
console.log(rest); // Output: [3, 4, 5]

Данный пример показывает как используется деструктуризация для извлечения first, second и rest значений из массива numbers. В JavaScript деструктуризация синтаксиса остаточного оператора(...) используется для извлечения всех не упомянутых значений в переменную rest.

Деструктуризация объектов

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

Вот пример:

const person = {
  name: 'Paul Knulst',
  role: 'Tech Lead',
  address: {
    street: 'Blogstreet',
    city: 'Anytown',
    country: 'Germany'
  }
};

const {name, role, address: {city, ...address}} = person;

console.log(name); // Output: Paul Knulst
console.log(role); // Output: Tech Lead
console.log(city); // Output: Anytown
console.log(address);

Этот пример показывает создание объекта представляющего человека(меня) и деструктурирующего свойства name, role, address.city и address.street/address.country в переменные name, role, city и address.

Деструктуризация со значениями по умолчанию

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

Пример:

const person = {
  name: 'Paul Knulst',
  role: 'Tech Lead'
};

const {name, role, address = 'Unknown'} = person;

console.log(name); // Output: Paul Knulst
console.log(role); // Output: Tech Lead
console.log(address); // Output: Unknown

Этот пример также показывает объект представляющий человека и затем использует деструктуризацию для извлечения свойств name и role в переменные name и role. Дополнительно используется значение Unknown для переменной address, потому что у объекта person нет свойства address.

Заключительные заметки

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

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

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

С этими продвинутыми концепциями JavaScript в вашем вооружении возможности того, что вы можете создать с помощью данного языка становятся поистине безграничными.

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

Вы всегда можете найти меня на Medium, Linkedin, Twitter и GitHub.

Спасибо за прочтение и счастливого кодинга!

Комментарии (5)