Опубліковано
8 хв читання

Цікаві запитання зі світу JavaScript

Автори
  • Learna
    Ім’я
    Learna

Навіщо ця стаття?

Тут зібрані цікаві ситуації з JavaScript: одні важливо пам’ятати в роботі, інші просто допомагають краще зрозуміти мову. Це добірка неочевидних «JS-моментів», з якими ми стикаємося не щодня. Більшість завдань — у форматі «вгадайте результат» або «чому це не працює».

Порядок виконання в циклі подій: promises, queueMicrotask.

Вгадайте порядок виведення.

Promise.resolve().then(() => console.log(1));

queueMicrotask(() => console.log(2));

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

console.log(4);

new Promise(() => {
    console.log(5);
});

(async () => console.log(6))();
Відповідь

4, 5, 6, 1, 2, 3

Пояснення:

  1. console.log(4) виконується синхронно.

  2. Тіло виконавця new Promise() також виконується синхронно.

  3. Асинхронний функціональний вираз, який одразу викликається (IIFE):

    (async () => console.log(1))();
    new Promise(() => console.log(2));
    console.log(3);
    // 1, 2, 3
    
  4. Коли синхронний код завершився, обробляється черга мікрозавдань. Першим у ній стоїть обробник уже виконаного Promise: then(() => console.log(1)).

  5. Далі виконується колбек queueMicrotask(() => console.log(2)) із тієї самої черги.

  6. Після спорожнення мікрозавдань цикл може перейти до наступного завдання — колбека setTimeout(() => console.log(3), 0).

Що з наведеного не є мікрозавданням?

a. Завантаження скрипту
b. Колбек setTimeout
c. Подія mousemove
d. Колбек requestAnimationFrame
e. Рендеринг інтерфейсу
f. Колбек then
g. Функція-виконавець new Promise
h. Виклики fetch

Відповідь

Усі перелічені пункти, крім колбека then. Сам виклик fetch не є мікрозавданням, хоча обробники повернутого ним Promise використовують чергу мікрозавдань. Виконавець new Promise запускається синхронно.

Порядок виконання в циклі подій: promises, async/await.

Яке значення буде останнім?

async function asyncFunction() {
    console.log(1);
    new Promise(() => console.log(2));
    await new Promise((res) => {
        setTimeout(() => console.log(3), 0);
        res();
    });
}

new Promise((res) => {
    console.log(4);
    (async () => {
        console.log(5);
        await asyncFunction()
        console.log(6);
    })();
    res();
}).then(() => console.log(7));

console.log(8);
Відповідь

4, 5, 1, 2, 8, 6, 7, 3

Що саме відбулося?

Реакція на несподіваний результат
  1. Виконавець зовнішнього new Promise синхронно виводить 4.
  2. IIFE доходить до await: спочатку синхронно виводить 5 і викликає asyncFunction.
  3. asyncFunction виводить 1; виконавець вкладеного Promise — 2.
  4. Усередині Promise, якого очікує asyncFunction, планується таймер із виведенням 3 і викликається res().
  5. Продовження asyncFunction після await стає мікрозавданням. IIFE також очікує завершення asyncFunction.
  6. Зовнішній Promise завершується, його .then() планує виведення 7.
  7. Решта синхронного скрипту виводить 8.
  8. Продовження asyncFunction виводить 6 і завершує функцію, після чого може продовжитися IIFE.
  9. Виконується запланований .then() із 7, а згодом — таймер із 3.

Важливо розрізняти виклик async-функції, який починається синхронно, і її продовження після await, яке планується окремо.

Порядок виконання в циклі подій: async, Promise.all.

Яке значення буде першим?

(async () => {
    const asyncFunc = async () => "asyncFunc";

    const promise = new Promise(() => {
        console.log("promise");
    }).then(() => console.log("then"));

    console.log("asyncBody");

    queueMicrotask(() => {
        console.log("queueMicrotask");
    });

    const results = await Promise.all([asyncFunc(), promise]);

    return results;
})();

console.log("script");
Відповідь

promise, asyncBody, script, queueMicrotask

Чому не виконується then із шостого рядка? Promise, створений у четвертому рядку, ніхто не завершує: він залишається у стані pending.

Області видимості та замикання

Основні області видимості JavaScript — глобальна, функціональна й блокова; модулі також мають власну область видимості.

const name = "John" ;        // Global Scope
{
    const name = "John";     // Block Scope
}
function foo() {
    const name = "John";     // Function Scope
}

Якщо функція визначена всередині іншої, вона може звертатися до змінних зовнішньої області видимості. Замикання — це функція разом із доступом до її лексичного оточення. У JavaScript замикання формується під час створення функції.

Яким буде результат?

function createCounter() {
    let global = 0;
    function incrementCounter() {
        const incremented = ++global;
        return incremented;
    }
    return { incrementCounter };
}

const counter = createCounter();

console.log(counter.incrementCounter());
console.log(counter.incrementCounter());
console.log(createCounter().incrementCounter());
Відповідь

1, 2, 1

Чи рівні ці об’єкти?

function userFactory() {
    let user = { name: "", createAt: Date.now() };

    return (name) => {
        "use strict";
        user.name = name;
        user.createAt = Date.now();
        return user;
    };
}
const createUser = userFactory();
console.log(createUser("John") === createUser("NotJohn"));
Відповідь

Так, це той самий об’єкт.

userFactory — помилкова реалізація фабрики: вона повертає той самий об’єкт, а мала б створювати новий. Водночас це наочний приклад замикання.

Порівнюючи об’єкти через ===, ми перевіряємо їхню ідентичність, а не рівність вмісту.

Що таке hoisting?

Слово hoisting («підняття оголошень») може вводити в оману. Рушій JavaScript не переміщує рядки на початок файлу. То що ж насправді означає цей термін?

Здивована реакція Джої

Hoisting описує поведінку оголошень до того, як виконання дійде до їхнього місця в коді. До пояснення повернемося трохи нижче, а спочатку — запитання.


Які твердження правильні?

  • a. Hoisting фізично переміщує функції та змінні на початок файлу.
  • b. Оголошення let і const впливають на область видимості ще до свого рядка, але до ініціалізації недоступні.
  • c. Змінні var до виконання присвоєння залишаються неініціалізованими.
  • d. Hoisting — це переставляння рядків під час виконання.
  • e. Статичні import доступні в модулі незалежно від їхнього текстового розташування.
Відповідь

b and e

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

  1. Підготовка оточення.
  2. Виконання коду.

Під час підготовки оточення:

  • Формуються зв’язки для змінних і функцій у відповідній області видимості. Глобальний об’єкт створює середовище виконання, а не кожен файл.
  • var ініціалізується значенням undefined.
  • Оголошення функцій пов’язуються з функціями; let і const до своєї ініціалізації залишаються в temporal dead zone.

Під час виконання:

  • Рушій обчислює вирази й виконує інструкції згідно з потоком керування.

Приклади hoisting

const framework = "Next.js";
let renderingPattern = "SSR";
var bestProgrammingLanguage = "HTML😁";

До виконання оголошень let і const їхні зв’язки вже існують, але ще не ініціалізовані. var bestProgrammingLanguage натомість має значення undefined. Присвоєння з прикладу відбуваються під час виконання відповідних рядків.

Розгляньмо ще один приклад:

var counter = 1;

function resetCounter() {
    console.log(counter);   // undefined
    var counter = 0;
    console.log(counter);   // 0
};

resetCounter();

А тепер приклад із помилкою:

let counter = 1;

function resetCounter() {
    console.log(counter);
    const counter = 0;
    console.log(counter);
};

resetCounter();

Цей код викине Uncaught ReferenceError: Cannot access 'counter' before initialization.

Виконання доходить до console.log(counter) раніше, ніж ініціалізується counter, тому виникає помилка. Зв’язок для змінної вже існує в лексичному оточенні функції, але читати його ще не можна. Це відрізняється від звернення до зовсім неоголошеної змінної. Порівняйте повідомлення на зображенні:

Помилка звернення до змінної до ініціалізації

Отже, hoisting — узагальнена назва поведінки оголошень до їхнього текстового місця. Не всі оголошення отримують однакове початкове значення: var, функції, let, const та імпорти мають різні правила.

Що таке this?

Реакція на поведінку this

Значення this залежить від контексту та способу виклику.
Верхній рівень: у звичайному браузерному скрипті це глобальний об’єкт, але у ES-модулі — undefined; CommonJS у Node.js має власні правила.
Звичайна функція: для виклику obj.method() значенням є obj. Його також можна явно задати через call, apply або bind.
Стрілкова функція: не створює власного this, а бере його з лексичного оточення.
Класи: у методі екземпляра, викликаному як instance.method(), це екземпляр; від’єднання методу від об’єкта може змінити результат.
Суворий режим: звичайний виклик функції без об’єкта дає undefined, а не глобальний об’єкт.

Приклади нижче передбачають звичайний несуворий скрипт. У ES-модулі або strict mode частина результатів буде іншою.

Приклади this

Чи зможете ви правильно відповісти на всі запитання?

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

const thatObj = {
    printContext,
    printContext_1() {
        return printContext();
    },
    printContext_2() {
        return thatObj.printContext();
    },
};

thatObj.printContext();
thatObj.printContext_1();
thatObj.printContext_2();
Відповідь

thatObj, global, thatObj

  • thatObj.printContext() викликається як метод об’єкта, тому this — thatObj.
  • thatObj.printContext_1 містить результат попереднього виклику функції, а не прив’язаний до thatObj метод.
  • thatObj.printContext_2() усередині делегує виклик через this.printContext().
const thatObj_1 = {
    foo() {
        console.log(this);
    },
    bar: () => {
        console.log(this);
    },
};

const thatObj_2 = {
    foo: thatObj_1.foo,
    bar: () => thatObj_1.bar(),
    baz() {
        thatObj_1.foo();
    },
    bam() {
        thatObj_1.foo.call(this);
    },
    bah() {
        const nestedF = () => {
            console.log(this);
        };
        nestedF();
    },
};

thatObj_2.foo();
thatObj_2.bar();
thatObj_2.baz();
thatObj_2.bam();
thatObj_2.bah();
Відповідь

thatObj_2, global, thatObj_1, thatObj_2, thatObj_2

  • thatObj_2.bam() викликає foo з this, що дорівнює thatObj_2. Для звичайного методу важливе не місце оголошення, а спосіб виклику.
  • Стрілка, яку повертає thatObj_2.bah(), запам’ятовує this зовнішнього виклику — thatObj_2.
const thatObj = {
    printContext() {
        console.log(this);
    },
    printContext_1() {
        function nestedF() {
            console.log(this);
        };
        nestedF.apply(this);
    },
};

const { printContext, printContext_1 } = thatObj;

printContext();
printContext_1();
thatObj.printContext();
thatObj.printContext_1();
Відповідь

global, global, thatObj, thatObj

  • Після деструктуризації printContext() і printContext_1() викликаються як звичайні функції без об’єкта. У несуворому скрипті це дає глобальний this; у суворому режимі було б undefined.

Чи можна обійти масивоподібний об’єкт за допомогою Array.prototype.forEach?

Так — метод можна позичити через call 😊.

const arrayLike = {
  length: 3,
  0: 2,
  1: 3,
  2: 4,
  3: 5, // ignored by forEach() since length is 3
};

Array.prototype.forEach.call(arrayLike, (x) => console.log(x)); // 2, 3, 4

Дякую за читання! Сподіваюся, було корисно й трохи весело. До зустрічі! 👋

Подяка за читання