Найкращий спосіб вивчити JS - почати на ньому писати.
Для цього треба знати, як працює мова, і саме на цьому ми зупинимося у цій главі. Навіть якщо ви раніше програмували на інших мовах, не поспішайте при знайомстві з JS, і обов’язково відпрацьовуйте кожен нюанс.
Ця глава не є вичерпним довідником з синтаксису мови JS. Вона також не призначена бути повноцінним "вступом до JS".
Натомість ми оглянемо деякі з основних тематичних розділів мови. Наша мета – покращити відчуття мови, щоб ми могли рухатись далі й писали програми з більшою впевненістю. В решті цієї книги та в інших книгах серії ми повернемося до багатьох з цих тем та розглянемо їх детальніше.
Будь ласка, не очікуйте швидко впоратися з цим розділом. Він довгий та містить багато деталей, над якими варто подумати. Не поспішайте.
Для цього потрібно знати, як працює мова, і саме на цьому ми зупинимося у цьому розділі. Навіть якщо ви раніше програмували на інших мовах, не поспішайте, вивчаючи JS, і обов’язково відпрацьовуйте кожен новий аспект.
ПОРАДА: |
---|
Якщо щойно почали знайомитися з JS, я пропоную вам закласти багато часу для роботи над цим розділом. Візьміть кожну частину і розмірковуйте над нею, заглибтеся в тему. Перегляньте наявні програми на JS і порівняйте те, що ви бачите в них, з кодом та поясненнями (та поглядами!), які представлені тут. Маючи міцне розуміння природи JS, ви отримаєте набагато більше від решти книги та всієї серії. |
Майже кожен вебсайт або вебзастосунок, який ви використовуєте, складається з безлічі JS-файлів (зазвичай це файли з розширенням .js). Спокусливо розглядати їх усі (весь застосунок) як одну програму. Але JS бачить ситуацію інакше.
У JS кожен файл - це окрема програма.
Для розуміння процесу обробки помилок важливо це усвідомлювати. Оскільки JS обробляє файли як окремі програми, один файл може спричинити помилку (під час парсингу, компіляції або виконання), і це не обов'язково заважатиме обробці наступного файлу. Очевидно, що якщо ваша програма складається з п’яти .js-файлів, а один з них містить помилку, в кращому випадку застосунок буде працювати лише частково. Важливо переконатися, що кожен файл працює належним чином, і, наскільки це можливо, обробляє помилки в інших файлах якомога граційніше.
Можливо, вас здивує розгляд окремих .js-файлів як окремих програм, адже з точки зору користувача це одна велика програма. Це можливо, бо виконання застосунку дозволяє цим окремим програмам взаємодіяти та діяти як одна програма.
ЗАУВАЖЕННЯ: |
---|
У багатьох проєктах використовуються інструменти процесу збірки, які в підсумку об'єднують окремі файли проєкту в один файл для доставки на веб-сторінку. Коли це трапляється, JS обробляє цей єдиний файл як програму. |
Єдиний спосіб, в який кілька автономних .js-файлів діють як одна програма, - це спільний доступ до їх стану (і до їх публічних функціональних можливостей) через "глобальну область видимості". Вони поєднуються в цьому просторі імен глобальної області видимості, тому під час виконання діють як єдине ціле.
На додаток до типового окремого формату програми з моменту появи ES6 JS також підтримує формат модулів. Модулі також базуються на файлах. Якщо файл завантажується за допомогою механізму завантаження модуля, такого як оператор import
або тег <script type = module>
, весь код файлу розглядається як єдиний модуль.
Хоча ви зазвичай не думаєте про модуль, тобто, сукупність стану та публічних методів для роботи з цим станом, як про автономну програму, насправді JS все одно трактує кожен модуль окремо. Подібно до того, як "глобальна область видимості" дозволяє автономним файлам змішуватися під час виконання, імпорт одного модуля в інший дозволяє взаємодію між ними.
Незалежно від того, який шаблон організації коду (і механізм завантаження) використовується для певного файлу (як окремий файл або модуль), ви все одно повинні думати про кожен файл як про окрему (міні-)програму, яка потім може взаємодіяти з іншими (міні-)програмами для виконання функції вашого застосунку.
Найбільш фундаментальною одиницею інформації в програмі є значення(value). Значення - це дані. За допомогою значень програма зберігає свій стан. Значення у JS бувають у двох формах: примітивні значення та об'єкти.
Значення вводяться у програми за допомогою літералів:
greeting("My name is Kyle.");
У цій програмі значення "My name is Kyle."
є примітивним рядковим літералом; рядки - це впорядковані набори символів, які зазвичай використовуються для представлення слів і речень.
Я використав символ подвійних лапок "
, щоб розмежувати (оточити, відокремити, визначити) рядкове значення. Але я міг використати і символ з одинарними лапками '
. Вибір символу лапок цілком стилістичний. Важливо заради читабельності та простоти підтримки коду обрати один вид лапок і використовувати його послідовно по усій програмі.
Іншим варіантом розмежування рядкового літерала є використання зворотних лапок: ``` ``. Однак цей вибір впливає не тільки на стиль коду; також з'являється різниця у поведінці. Розглянемо:
console.log("My name is ${ firstName }.");
// My name is ${ firstName }.
console.log('My name is ${ firstName }.');
// My name is ${ firstName }.
console.log(`My name is ${ firstName }.`);
// My name is Kyle.
Припускаючи, що програма вже визначила змінну firstName
із рядковим значенням "Kyle"
, рядок з ``
обрахує поточне значення виразу, відокремленого символами ${ .. }
, та підставить це значення. Це називається інтерполяцією.
Рядок зі зворотними лапками `
можна використовувати і без інтерпольованих виразів, але це суперечить цілі цього альтернативного синтаксису рядкового літерала:
console.log(
`Am I confusing you by omitting interpolation?`
);
// Чи не дивно, що тут немає інтерполяції?
Кращий підхід - використовувати для рядків "
або '
(нагадаю, що треба вибрати щось одне і дотримуватися свого вибору), хіба що вам потрібна інтерполяція. Залиште `
лише для рядків, які містять інтерпольовані вирази.
Крім рядків, програми на JS часто містять інші літерали примітивних значень, такі як булеві значення та числа:
while (false) {
console.log(3.141592);
}
while
являє собою тип циклу: спосіб повторення операцій, доки(тобто, "while" англійською) умова відповідає дійсності.
У наведеному прикладі цикл ніколи не запускатиметься і у консоль нічого не буде виведено, оскільки ми використали логічне значення false
для умови. Натомість true
призвело б до створення нескінченного циклу, будьте обережні з цим!
Число 3.141592
- це, як ви знаєте, наближення математичного числа PI до перших шести цифр. Однак замість того, щоб вставляти таке значення, ви зазвичай використовуєте заздалегідь визначене значення Math.PI
для цієї мети. Ще однією варіацією чисел є примітивний тип bigint
(велике ціле число), який використовується для зберігання довільно великих чисел.
Числа найчастіше використовуються в програмах для підрахунку кроків, як-от ітерацій циклу, та для доступу до інформації в числових позиціях (тобто як індекс масиву). До масивів та об’єктів ми ще дійдемо, але ось приклад. Якби існував масив на ім'я `names ', ми могли б отримати доступ до елемента на другій позиції так:
console.log(`My name is ${ names[1] }.`);
// My name is Kyle.
Для елемента у другій позиції ми використовували індекс 1
, а не 2
, оскільки подібно до більшості мов програмування, індекси масивів у JS починаються з 0 (де 0
- перша позиція).
На додаток до рядків, чисел та булевих значень, у програмах на JS є ще два примітивні значення: null
та undefined
. Хоча між ними існують відмінності (як історичні, так і сучасні), здебільшого обидва значення служать меті вказати на порожнечу (або відсутність) значення.
Багато розробників вважають за краще ставитися до них обох послідовно таким чином, тобто, вважати, що значення null
та undefined
не відрізняються. Якщо зберігати обережність, часто це можливо. Однак найбезпечніше і найкраще використовувати як вказівку на порожнє значення лише undefined
, хоча null
здається привабливим тим, що його швидше писати.
while (value != undefined) {
console.log("Still got something!");
}
Останнім примітивним значенням, про яке слід пам’ятати, є символ – спеціальне значення, що поводиться як приховане значення, яке не можна вгадати. Символи майже завжди використовуються як спеціальні ключі на об'єктах:
hitchhikersGuide[ Symbol("meaning of life") ];
// 42
В типових програмах на JS ви нечасто зустрінете пряме використання символів. Вони в основному використовуються в низькорівневому коді, такому як код бібліотек та фреймворків.
Окрім примітивів, іншим типом значення в JS є об'єкт.
Як вже згадувалося раніше, масиви - це особливий тип об'єкта, який складається з упорядкованого та індексованого числами списку даних:
var names = [ "Frank", "Kyle", "Peter", "Susan" ];
names.length;
// 4
names[0];
// Frank
names[1];
// Kyle
Масиви в JS можуть містити значення будь-яких типів, примітивні або об’єктні (зокрема інші масиви). Як ми побачимо наприкінці розділу 3, навіть функції можна зберігати в масивах або об'єктах.
ЗАУВАЖЕННЯ: |
---|
Функції, як масиви, є особливим видом (він же підтип) об'єкта. Ми розглянемо функції детальніше дуже скоро. |
Об'єкти є більш загальними: це невпорядкована колекція довільних значень з доступом за ключем. Іншими словами, ви отримуєте доступ до елемента за допомогою назви місця його розташування, а не за числовим положенням, як у масивів. Ми називаємо ці назви "ключами" або "властивостями". Наприклад:
var me = {
first: "Kyle",
last: "Simpson",
age: 39,
specialties: [ "JS", "Table Tennis" ]
};
console.log(`My name is ${ me.first }.`);
Тут me
представляє об'єкт, а first
- назву місця розташування інформації в цьому об'єкті (в колекції значень). Інший варіант синтаксису, який дозволяє отримати доступ до інформації в об'єкті за його властивістю або ключем, використовує квадратні дужки [ ]
, наприклад me["first"]
.
Якщо потрібно визначити тип значення, оператор typeof
повідомить вам його вбудований тип: один з примітивних типів або "object"
:
typeof 42; // "number"
typeof "abc"; // "string"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof null; // "object" -- oops, bug!
typeof { "a": 1 }; // "object"
typeof [1,2,3]; // "object"
typeof function hello(){}; // "function"
ПОПЕРЕДЖЕННЯ: |
---|
typeof null на жаль повертає "object" замість очікуваного "null" . Крім того, typeof повертає "function" для функцій, проте не розраховуйте не "array" для масивів. |
Перетворення з одного типу значення на інший, наприклад з рядка на число, в JS називається "приведенням типів". Ми розглянемо цю тему докладніше далі в цій главі.
Примітивні значення та значення об’єктного типу поводяться по-різному, коли їх присвоюють або передають. Ми розглянемо ці подробиці в Додатку А "Значення та посилання: в чому різниця".
Проговоримо про те, що, можливо, не було очевидним з попереднього розділу: у програмах на JS значення можуть мати форму літералів (як це показано на багатьох попередніх прикладах), або вони можуть міститися у змінних; розглядайте змінні як просто контейнери для значень.
Змінні повинні бути оголошені (створені) до використання. Існують різні форми синтаксису, які оголошують змінні (вони ж "ідентифікатори"), і кожна форма передбачає певну поведінку.
Наприклад, розглянемо інструкцію var
:
var myName = "Kyle";
var age;
Ключове слово var
оголошує змінну, яка буде використана в цій частині програми, і, за бажанням, дозволяє присвоїти початкове значення.
Іншим подібним ключовим словом є let
:
let myName = "Kyle";
let age;
Ключове слово let
має деякі відмінності від var
, причому найбільш очевидним є те, що let
краще дозволяє обмежувати доступ до змінної, ніж var
. Це називається блоковою областю видимості, на противагу звичайній або "функційній" області видимості.
Розглянемо:
var adult = true;
if (adult) {
var myName = "Kyle";
let age = 39;
console.log("Shhh, this is a secret!");
}
console.log(myName);
// Kyle
console.log(age);
// Error!
Спроба отримати доступ до age
поза інструкцією if
призводить до помилки, оскільки age
був обмежений областю видимості до if
, тоді як myName
– ні.
Блокова область видимості дуже корисна для обмеження поширеності оголошень змінних у наших програмах, що допомагає запобігти випадковому перекриванню їх імен.
Але var
все-таки корисний тим, що дає зрозуміти: "ця змінна буде розглядатися в рамках ширшої області видимості (всієї функції)". Обидві форми оголошення можуть відповідати будь-якій частині програми залежно від обставин.
ЗАУВАЖЕННЯ: |
---|
Широко поширена думка, що слід уникати var на користь let (або const тощо), як правило, через помітну плутанину щодо того, як область видимості var працювала з початку JS. Я вважаю, що це надмірно обмежувальна і, зрештою, шкідлива порада. Припускається, що ви не можете навчитися правильно використовувати цю можливість мови в поєднанні з іншими. Я вірю, що ви можете і повинні вивчити будь-які доступні можливості мови та використовувати їх там, де це доречно! |
Третя форма оголошення - const
. Це як let
, але з додатковим обмеженням: цій змінній потрібно надати значення в момент оголошення, і пізніше їй не можна призначити інше.
Розглянемо:
const myBirthday = true;
let age = 39;
if (myBirthday) {
age = age + 1; // OK!
myBirthday = false; // Error!
}
Константа myBirthday
не може бути перепризначена.
Змінні, оголошені через const
, не є "незмінними", їх просто неможливо перепризначити. Не рекомендується використовувати const
зі значеннями об'єктів, оскільки ці значення все ще можуть бути змінені, навіть попри те, що змінну не можна перепризначити. Це призводить до потенційної плутанини, тому я думаю, що розумно уникати ситуацій, як ця:
const actors = [
"Morgan Freeman", "Jennifer Aniston"
];
actors[2] = "Tom Cruise"; // OK :(
actors = []; // Error!
Найбільш коректне з семантичної точки зору використання const
- це коли у вас є просте примітивне значення, якому ви хочете дати корисне ім'я, наприклад, використовувати myBirthday
замість true
. Це полегшує читання програм.
ПОРАДА: |
---|
Якщо ви дотримуєтесь використання const лише з примітивними значеннями, ви не будете плутати перепризначення (яке заборонене) та мутацію значення (це можна). Це найбезпечніший і найкращий спосіб використання const . |
Окрім var
, let
і const
, існують інші синтаксичні форми, які оголошують ідентифікатори (змінні) у різних областях видимості. Наприклад:
function hello(myName) {
console.log(`Hello, ${ myName }.`);
}
hello("Kyle");
// Hello, Kyle.
Ідентифікатор hello
створюється у зовнішній області видимості, і він також автоматично поєднується зі значеннями, щоб посилатися на функцію. Але іменований параметр myName
створюється лише всередині функції і доступний лише в області видимості цієї функції. hello
та myName
в цілому поводяться як змінні, оголошені через var
.
Іншим синтаксисом, який оголошує змінну, є інструкція catch
:
try {
someError();
}
catch (err) {
console.log(err);
}
err
- це змінна з блоковою областю видимості, яка існує лише всередині блоку "catch", наче вона була оголошена за допомогою let
.
Слово "функція" має різноманітне значення в програмуванні. Наприклад, у світі функційного програмування "функція" має точне математичне визначення і передбачає строгий набір правил, яких слід дотримуватися.
У JS ми маємо розглядати термін "функція" як розширення значення іншого спорідненого терміна: "процедура". Процедура - це сукупність інструкцій, що можна викликати один або кілька разів, які можуть отримувати деякі вхідні дані і можуть повертати одне або кілька вихідних значень.
З перших днів JS визначення функції виглядало так:
function awesomeFunction(coolThings) {
// ..
return amazingStuff;
}
Це називається декларацією функції, оскільки вона виступає як інструкція сама по собі, а не як вираз в іншій інструкції. Зв'язок між ідентифікатором awesomeFunction
і значенням функції створюється під час фази компіляції коду, перш ніж цей код буде виконаний.
На відміну від оператора оголошення функції, функційний вираз можна визначити та призначити таким чином:
// let awesomeFunction = ..
// const awesomeFunction = ..
var awesomeFunction = function(coolThings) {
// ..
return amazingStuff;
};
Ця функція є виразом, який присвоюється змінній awesomeFunction
. На відміну від форми оголошення функції, вираз функції не пов'язаний з її ідентифікатором, поки ця інструкція не буде виконана під час виконання.
Надзвичайно важливо зазначити, що в JS функції - це значення, які можна призначити (як показано в цьому фрагменті) і передавати. Насправді функції JS є особливим підтипом об'єктного типу. Не всі мови розглядають функції як значення, але для мови, яка подібно до JS хоче підтримувати функційний шаблон програмування, це критично важливо.
Функції в JS можуть отримувати параметри:
function greeting(myName) {
console.log(`Hello, ${ myName }!`);
}
greeting("Kyle"); // Hello, Kyle!
У цьому фрагменті коду myName
називається параметром, який діє як локальна змінна всередині функції. Функції можуть бути визначені для отримання будь-якої кількості параметрів або жодного, як вам зручно. Кожному параметру присвоюється значення аргументу, яке ви передаєте в цій позиції під час виклику. В нашому прикладі – "Кайл".
Також функції можуть повертати значення за допомогою ключового слова return
:
function greeting(myName) {
return `Hello, ${ myName }!`;
}
var msg = greeting("Kyle");
console.log(msg); // Hello, Kyle!
Ви можете повернути лише одне значення, але якщо у вас є більше значень для повернення, ви можете огорнути їх одним об'єктом чи масивом.
Оскільки функції є значеннями, їх можна призначати властивостями об'єктів:
var whatToSay = {
greeting() {
console.log("Hello!");
},
question() {
console.log("What's your name?");
},
answer() {
console.log("My name is Kyle.");
}
};
whatToSay.greeting();
// Hello!
У цьому фрагменті посилання на три функції (greeting()
, question()
та answer()
) включені в об'єкт, що знаходиться у змінній whatToSay
. Кожну функцію можна викликати, звертаючись до властивості об'єкта по посилання на потрібну функцію. Порівняйте цей прямий стиль визначення функцій на об'єкті з більш витонченим синтаксисом класів, який ми обговорюватимемо далі в цій главі.
Є багато різноманітних форм, які приймають функції в JS. Ми розбираємось у цих варіаціях у Додатку А, "Так багато форм функцій".
Прийняття рішень у програмах вимагає порівняння значень для визначення їхньої ідентичності та відносин між ними. JS пропонує кілька механізмів порівняння значення, тож розглянемо їх ближче.
Найпоширеніше порівняння в програмах JS ставить запитання: "Чи значення X це те саме, що значення Y?" Що означає "те саме" для JS?
З ергономічних та історичних причин значення рівності є складнішим, ніж очевидне точний збіг. Іноді порівняння рівності має на меті точне узгодження, але інший раз бажане порівняння є дещо ширшим, дозволяючи близько подібне або взаємозамінне узгодження. Іншими словами, ми повинні пам’ятати про нюансні відмінності між порівнянням рівності та порівнянням еквівалентності.
Якщо ви попрацювали з JS вже деякий час і читали про неї, ви, безсумнівно, бачили так званий оператор "потрійна рівність" ===
, який також називають оператором "строгої рівності". Його значення здається досить очевидним, чи не так? Авжеж, "строгий" означає строгий, як у вузькому та точному сенсі.
Не зовсім.
Так, більшість значень, що беруть участь у порівнянні рівності через ===
, відповідають опису "це те саме". Розглянемо кілька прикладів:
3 === 3.0; // true
"yes" === "yes"; // true
null === null; // true
false === false; // true
42 === "42"; // false
"hello" === "Hello"; // false
true === 1; // false
0 === null; // false
"" === null; // false
null === undefined; // false
ПРИМІТКА: |
---|
Інший спосіб порівняння рівності, яким часто описується рівність === , це "перевірка як значення, так і типу". У кількох прикладах, які ми розглядали до цього часу, наприклад, 42 ==="42" , тип обох значень (число, рядок тощо), здається, є відмітним фактором. Однак справа не тільки у цьому. Усі порівняння значень у JS враховують тип значень, а не тільки оператор === . Точніше кажучи, === забороняє будь-яке перетворення типу (будь-яке "приведення типів") при порівнянні, тоді як інші порівняння в JS дозволяють приведення типів. |
Але оператор ===
має певний нюанс. Це факт, який багато розробників JS продивляються на свою шкоду. Оператор ===
призначений брехати у двох випадках особливих значень: NaN
та -0
. Розглянемо:
NaN === NaN; // false
0 === -0; // true
У випадку NaN
оператор ===
бреше і каже, що входження NaN
не дорівнює іншому NaN
. У разі -0
(так, це справжнє, чітке значення, яке ви можете умисно використовувати у своїх програмах!), Оператор ===
бреше і каже, що воно дорівнює звичайному значенню 0
.
Оскільки брехня щодо таких порівнянь може бути проблемою, краще уникати використання ===
для них. Для порівняння NaN
використовуйте утиліту Number.isNaN(..)
, яка не бреше. Для порівняння -0
використовуйте утиліту Object.is(..)
, яка також не бреше. Object.is(..)
також можна використовувати для чесних перевірок на NaN
, якщо вам подобається. Жартома ви могли б розглядати Object.is(..)
як "чотирикратну рівність ====
, чесно-чесно строге порівняння.
Існують глибші історичні та технічні причини для цієї брехні, але це не змінює той факт, що ===
насправді не є строго точно рівним порівнянням, у найсуворішому розумінні.
Історія ускладнюється ще більше, коли ми розглядаємо порівняння значень об’єктів, а не примітивних значень. Розглянемо:
[ 1, 2, 3 ] === [ 1, 2, 3 ]; // false
{ a: 42 } === { a: 42 } // false
(x => x * 2) === (x => x * 2) // false
Що тут відбувається?
Може здатися розумним припустити, що перевірка рівності враховує природу або вміст значення; врешті-решт, 42 === 42
враховує фактичне значення 42
і порівнює його. Але коли справа стосується об’єктів, порівняння, що враховує зміст, зазвичай називають „структурною рівністю”.
JS не визначає ===
як структурну рівність для значень об'єкта. Натомість ===
використовує рівність за ідентичністю для значень об'єкта.
У JS усі значення об'єктів зберігаються за посиланням (див. "Значення та посилання: в чому різниця" у Додатку А), присвоюються та передаються за допомогою посилання-копії та в контексті нашого поточного обговорення, порівнюються за посиланням (перевіряються на ідентичність). Розглянемо:
var x = [ 1, 2, 3 ];
// призначення виконується через копіювання посилання, тому
// y посилається на *той самий* масив, що і x,
// а не на окрему копію цього масиву.
var y = x;
y === x; // true
y === [ 1, 2, 3 ]; // false
x === [ 1, 2, 3 ]; // false
У цьому фрагменті значення y === x
є істинним, оскільки обидві змінні містять посилання на один і той самий початковий масив. Але порівняння === [1,2,3]
дає негативний результат, оскільки y
та x
, відповідно, порівнюються з новими іншим масивом [1,2,3]
. Структура масиву та вміст у цьому порівнянні не мають значення, лише ідентичність посилання.
JS не пропонує механізму порівняння структурної рівності значень об'єктів, лише порівняння за ідентичністю посилання. Для порівняння структурної рівності вам доведеться здійснити перевірку самостійно.
Але будьте обережні: це складніше, ніж ви думаєте. Наприклад, як ви можете визначити, чи є два посилання на функції "структурно еквівалентними"? Навіть порівняння коду функцій не візьме до уваги такі речі, як замикання. JS не забезпечує порівняння структурної рівності, оскільки майже неможливо описати всі межові випадки.
Приведення типів означає, що значення одного типу перетворюється на відповідне представлення цього значення в іншому типі (наскільки це можливо). Як ми обговоримо в розділі 4, приведення типів є однією з основних властивостей мови JS, а не якоюсь необов'язковою функцією, якої можна обґрунтовано уникнути.
Але там, де приведення типів зустрічається з операторами порівняння (наприклад, рівність), на жаль, сум'яття та розчарування, виникають особливо часто.
Мало можливостей JS викликають більший гнів у широкій JS-спільноті, ніж оператор ==
, який зазвичай називають оператором "вільної рівності". Більшість публікацій та відкритих дискусій про JS засуджує цей оператор як погано розроблений та небезпечний чи такий, що призводить до помилок в програмах на JS. Навіть сам творець мови, Брендан Ейх, нарікав, що це була велика помилка.
З того, що я можу сказати, більша частина цього розчарування походить від досить короткого списку заплутаних межових випадків, але глибшою проблемою є надзвичайно поширена хибна віра в те, що оператор ==
начебто проводить порівняння, не враховуючи типи порівняних значень.
Оператор ==
виконує порівняння рівності, аналогічно тому, як виконує його ===
. Насправді обидва оператори враховують тип значень, що порівнюються. І якщо порівняння проводиться між значеннями одного типу, як ==
, так і ===
роблять те саме.
Якщо типи значень, що порівнюються, різні, значення ==
відрізняється від ===
тим, що воно дозволяє приведення типів перед порівнянням. Іншими словами, обидва оператори прагнуть порівнювати значення одного типу, але ==
дозволяє спочатку перетворити типи операндів, і як тільки типи були перетворені на однакові з обох сторін, тоді ==
робить те саме, що і ===
. Замість "вільної рівності", оператор ==
слід описувати як "рівність з приведенням типів".
Порівняйте:
42 == "42"; // true
1 == true; // true
В обох порівняннях типи значень різні, тому значення ==
призводить до перетворення нечислових значень ("42"
та true
) на числа (42
та 1
відповідно) перед проведенням порівняння.
Просто усвідомлення цієї природи ==
, а саме те, що цей оператор віддає перевагу примітивним числовим порівнянням, допомагає вам уникнути більшості проблемних межових випадків, та триматися якомога далі від несподіванок, які можуть виникнути при порівняннях "" == 0
або 0 == false
.
Ви можете подумати: "Ну що ж, тоді я просто завжди уникатиму будь-якого порівняння з приведенням типів, тобто, завжди обиратиму ===
, щоб уникнути цих межових випадків!". Е, вибачте, це не зовсім так імовірно, як ви сподівались.
Існує досить великий шанс, що ви будете використовувати оператори реляційного порівняння, такі як <
, >
(і навіть <=
і >=
).
Так само як ==
, ці оператори будуть діяти так, ніби вони "строгі", якщо типи, що реляційно порівнюються, вже збігаються, але вони дозволять спочатку приведення типів (як правило, до чисел), якщо типи відрізняються.
Розглянемо:
var arr = [ "1", "10", "100", "1000" ];
for (let i = 0; i < arr.length && arr[i] < 500; i++) {
// виконається тричі
}
Порівняння i < arr.length
захищене від приведення типів, оскільки i
та arr.length
завжди є числами. Однак аргумент arr [i] < 500
викликає приведення типів, оскільки значення arr[i]
- це рядки. Таким чином, ці порівняння перетворюються на 1 < 500
, 10 < 500
, 100 < 500
та 1000 < 500
. Оскільки четвертий випадок не істинний, цикл зупиняється після третьої ітерації.
Ці реляційні оператори зазвичай використовують числові порівняння, за винятком випадку, коли обидва значення, що порівнюються, вже є рядками; в цьому випадку вони використовують алфавітне (словникове) порівняння рядків:
var x = "10";
var y = "9";
x < y; // true, будьте уважні!
Не існує жодного способу примусити ці реляційні оператори не робити приведення типів, окрім як просто ніколи не використовувати невідповідні типи в порівняннях. Це, мабуть, чудова ціль, але все ж досить ймовірно, що ви зіткнетеся з випадком, коли типи можуть відрізнятися.
Мудріший підхід полягає не в тому, щоб уникати примусових порівнянь, а в тому, щоб знати та розуміти їхні тонкощі.
Порівняння з приведенням типів з’являються в інших місцях JS, таких як умовні умови (if
тощо), до яких ми повернемося в Додатку А, "Умовне порівняння з приведенням типів".
Дві основні схеми організації коду, тобто, даних та поведінки, широко використовуються в екосистемі JS: класи та модулі. Ці шаблони не є взаємозаперечними; багато програм можуть і використовують обидва. Інші програми будуть дотримуватися лише одного шаблону, або навіть жодного.
У деяких аспектах ці шаблони дуже різні. Але цікаво, що в інших відношеннях це просто різні сторони однієї медалі. Щоб володіти JS, потрібно знати не тільки самі шаблони, але і де вони доречні, а де – ні.
Терміни "об'єктно-орієнтований", "класово-орієнтований" та "класи" дуже завантажені, деталізовані та багаті на нюанси; вони не універсальні у визначенні.
Ми будемо використовувати загальноприйняте і дещо традиційне визначення, ймовірно, знайоме людям із досвідом в "об'єктно-орієнтованих" мовах, як-от C++ та Java.
Клас у програмі - це визначення "типу" власної структури даних, що включає як дані, так і поведінку, що працює з цими даними. Класи визначають, як працює така структура даних, але самі класи не є конкретними значеннями. Щоб отримати конкретне значення, яке можна використовувати в програмі, клас треба інстанціювати (створити екземпляр класу за допомогою ключового слова new
) один або кілька разів.
Розглянемо:
class Page {
constructor(text) {
this.text = text;
}
print() {
console.log(this.text);
}
}
class Notebook {
constructor() {
this.pages = [];
}
addPage(text) {
var page = new Page(text);
this.pages.push(page);
}
print() {
for (let page of this.pages) {
page.print();
}
}
}
var mathNotes = new Notebook();
mathNotes.addPage("Arithmetic: + - * / ...");
mathNotes.addPage("Trigonometry: sin cos tan ...");
mathNotes.print();
// ..
У класі Page
дані є рядком тексту, що зберігається у властивості this.text
. Поведінкою є метод print()
, який виводить текст у консоль.
Для класу Notebook
дані є масивом екземплярів класу Page
. Поведінка – це метод addPage(..)
, який створює екземпляри нових сторінок Page
та додає їх до списку, а також print()
, який друкує всі сторінки зошиту.
Вираз mathNotes = new Notebook()
створює екземпляр класу Notebook
, а page = new Page(text)
- це місце, де створюються екземпляри класу Page
.
Поведінку (методи) можна викликати лише на екземплярах (а не на самих класах), таких як mathNotes.addPage(..)
та page.print()
.
Механізм класів дозволяє згрупувати дані (text
та pages
) разом з поведінкою (наприклад, addPage(..)
та print()
). Цю програму можна було б побудувати без будь-яких визначень класу, але вона, швидше за все, була б набагато гірше організованою, важчою для читання та міркувань, більш відкритою до помилок та незручною у підтримці.
Іншим аспектом, властивим традиційному "класово-орієнтованому" дизайну, хоча і дещо рідше використовуваному в JS, є "наслідування" (і "поліморфізм"). Розглянемо:
class Publication {
constructor(title,author,pubDate) {
this.title = title;
this.author = author;
this.pubDate = pubDate;
}
print() {
console.log(`
Title: ${ this.title }
By: ${ this.author }
${ this.pubDate }
`);
}
}
Цей клас Publication
визначає набір загальних особливостей поведінки, які можуть знадобитися будь-якій публікації.
Тепер розглянемо більш конкретні типи публікацій, такі як Book
та BlogPost
:
class Book extends Publication {
constructor(bookDetails) {
super(
bookDetails.title,
bookDetails.author,
bookDetails.publishedOn
);
this.publisher = bookDetails.publisher;
this.ISBN = bookDetails.ISBN;
}
print() {
super.print();
console.log(`
Publisher: ${ this.publisher }
ISBN: ${ this.ISBN }
`);
}
}
class BlogPost extends Publication {
constructor(title,author,pubDate,URL) {
super(title,author,pubDate);
this.URL = URL;
}
print() {
super.print();
console.log(this.URL);
}
}
Як Book
, так і BlogPost
використовують ключове слово "extends", щоб розширити загальне визначення Publication
додатковою поведінкою. Виклик super(..)
у кожному конструкторі делегує ініціалізацію конструкторові батьківського класу Publication
, а потім Book
та BlogPost
роблять більш конкретні дії відповідно до свого типу публікації (він же "підклас" або "дочірній клас").
Тепер розгляньте можливість використання таких дочірніх класів:
var YDKJS = new Book({
title: "You Don't Know JS",
author: "Kyle Simpson",
publishedOn: "June 2014",
publisher: "O'Reilly",
ISBN: "123456-789"
});
YDKJS.print();
// Title: You Don't Know JS
// By: Kyle Simpson
// June 2014
// Publisher: O'Reilly
// ISBN: 123456-789
var forAgainstLet = new BlogPost(
"For and against let",
"Kyle Simpson",
"October 27, 2014",
"https://davidwalsh.name/for-and-against-let"
);
forAgainstLet.print();
// Title: For and against let
// By: Kyle Simpson
// October 27, 2014
// https://davidwalsh.name/for-and-against-let
Зверніть увагу, що обидва екземпляри дочірнього класу мають метод print()
, який переписав метод print()
, успадкований від батьківського класу Publication
. Кожен із цих перевизначених дочірніх методів print()
викликає super.print()
, щоб викликати успадковану версію методу print()
.
Той факт, що як успадковані, так і перевизначені методи можуть мати однакову назву та співіснувати, називається поліморфізмом.
Наслідування є потужним інструментом, що організує дані та поведінку в окремі логічні одиниці (класи), але дозволяє дочірньому класу взаємодіяти з батьківським шляхом доступу до його даних або використання його поведінки.
Шаблон модуля має, по суті, ту ж мету, що і шаблон класу, а саме згрупувати дані та поведінку в логічні одиниці. Як і класи, модулі можуть "включати" або "отримувати доступ" до даних та поведінки інших модулів для взаємодії.
Але модулі мають деякі важливі відмінності від класів. Найголовніше відмінність полягає у синтаксисі.
ES6 додав до власного синтаксису JS форму синтаксису модуля, яку ми розглянемо за мить. Проте з перших часів існування JS модулі були важливим і загальновизнаним шаблоном, який використовувався у незліченних програмах JS, навіть без спеціального синтаксису.
Ключовими ознаками класичного модуля є зовнішня функція (яка відпрацьовує принаймні один раз), що повертає "екземпляр" модуля. Екземпляр модуля надає доступ до однієї або декількох функцій, що мають доступ до внутрішніх (прихованих) даних екземпляра модуля.
Оскільки модуль в такій формі є просто функцією, і його виклик створює "екземпляр" модуля, ці функції також можна описати як "фабрики модулів".
Розглянемо класичну форму модуля попередніх класів Publication
, Book
та BlogPost
:
function Publication(title,author,pubDate) {
var publicAPI = {
print() {
console.log(`
Title: ${ title }
By: ${ author }
${ pubDate }
`);
}
};
return publicAPI;
}
function Book(bookDetails) {
var pub = Publication(
bookDetails.title,
bookDetails.author,
bookDetails.publishedOn
);
var publicAPI = {
print() {
pub.print();
console.log(`
Publisher: ${ bookDetails.publisher }
ISBN: ${ bookDetails.ISBN }
`);
}
};
return publicAPI;
}
function BlogPost(title,author,pubDate,URL) {
var pub = Publication(title,author,pubDate);
var publicAPI = {
print() {
pub.print();
console.log(URL);
}
};
return publicAPI;
}
Порівнюючи ці форми з формами «класу», ми можемо відмітити, що є більше подібності, ніж відмінностей.
Форма class
зберігає методи та дані на екземплярі об'єкта, до яких можна отримати доступ з префіксом this.
. З модулями доступ до методів і даних здійснюється за ідентифікатором змінних в області видимості без жодного this.
З class
, програмний інтерфейс екземпляра є неявним у визначенні класу. Також усі дані та методи є загальнодоступними. За допомогою функції-фабрики модулів ви явно створюєте та повертаєте об'єкт з будь-якими публічними методами, а будь-які дані чи інші методи без посилань залишаються приватними всередині фабричної функції.
Існують інші варіанти цієї фабричної функції, досить поширеними в JS, навіть у 2020 році; ви можете запускати ці форми в різних програмах JS: AMD (Asynchronous Module Definition), UMD (Universal Module Definition) та CommonJS (класичні модулі в стилі Node.js). Між ними існують незначні варіації (модулі не зовсім сумісні). Однак усі ці форми спираються на ті самі принципи.
Розглянемо також використання (так зване створення екземпляру) цих фабричних функцій для створення модулів:
var YDKJS = Book({
title: "You Don't Know JS",
author: "Kyle Simpson",
publishedOn: "June 2014",
publisher: "O'Reilly",
ISBN: "123456-789"
});
YDKJS.print();
// Title: You Don't Know JS
// By: Kyle Simpson
// June 2014
// Publisher: O'Reilly
// ISBN: 123456-789
var forAgainstLet = BlogPost(
"For and against let",
"Kyle Simpson",
"October 27, 2014",
"https://davidwalsh.name/for-and-against-let"
);
forAgainstLet.print();
// Title: For and against let
// By: Kyle Simpson
// October 27, 2014
// https://davidwalsh.name/for-and-against-let
Єдиною помітною різницею тут є відсутність new
. Фабричні функції, що створюють модулі, викликаються як звичайні функції.
Модулі ES (ESM), введені в JS з версією ES6, за духом і метою мають відповідати щойно описаним класичним модулям, особливо враховуючи важливі варіації та випадки використання від AMD, UMD та CommonJS.
Однак підхід до реалізації істотно відрізняється.
По-перше, немає функції-обгортки для визначення модуля. Контекст обгортки - це файл. ESM завжди відповідають файлу; один файл, один модуль.
По-друге, ви не взаємодієте з програмним інтерфейсом модуля явно. Натомість ви використовуєте ключове слово export
, щоб додати змінну або метод до визначення його загальнодоступного інтерфейсу. Якщо щось визначено в модулі, але не експортовано, воно залишається прихованим (як і в класичних модулях).
По-третє, і, можливо, це найбільш помітна відмінність від раніше обговорюваних шаблонів, ви не "створюєте новий екземпляр" модуля ES, ви просто імпортуєте його єдиний наявний екземпляр для використання. ESM фактично є "сінглтоном", оскільки в вашій програмі був створений лише один екземпляр при першому import
, а всі інші import
просто отримують посилання на той самий екземпляр. Якщо ваш модуль має існувати в декількох екземплярах, для цього вам слід надати стандартну функцію класичного модуля у вашому визначенні ESM.
У нашому прикладі ми припускаємо багаторазове створення екземплярів, тому наступні фрагменти поєднують як ESM, так і класичні модулі.
Розглянемо файл publication.js
:
function printDetails(title,author,pubDate) {
console.log(`
Title: ${ title }
By: ${ author }
${ pubDate }
`);
}
export function create(title,author,pubDate) {
var publicAPI = {
print() {
printDetails(title,author,pubDate);
}
};
return publicAPI;
}
Щоб імпортувати та використовувати цей модуль з іншого модуля ES, такого як blogpost.js
, зробимо так:
import { create as createPub } from "publication.js";
function printDetails(pub,URL) {
pub.print();
console.log(URL);
}
export function create(title,author,pubDate,URL) {
var pub = createPub(title,author,pubDate);
var publicAPI = {
print() {
printDetails(pub,URL);
}
};
return publicAPI;
}
І нарешті, імпортуємо наш модуль в інший модуль ES, такий як main.js
:
import { create as newBlogPost } from "blogpost.js";
var forAgainstLet = newBlogPost(
"For and against let",
"Kyle Simpson",
"October 27, 2014",
"https://davidwalsh.name/for-and-against-let"
);
forAgainstLet.print();
// Title: For and against let
// By: Kyle Simpson
// October 27, 2014
// https://davidwalsh.name/for-and-against-let
ПРИМІТКА: |
---|
Конструкція as newBlogPost в інструкції import є необов’язковою; якщо її опустити, буде імпортовано функцію верхнього рівня з назвою create(..) . У цьому випадку я перейменовую його заради читабельності; newBlogPost(..) краще описує його семантику, ніж узагальнена назва create(..) . |
Як показано, ES-модулі всередині можуть використовувати класичні модулі, якщо їм потрібно підтримувати багаторазове створення екземплярів. Замість фабричної функції create(..)
ми могли б експортувати class
з нашого модуля з тим самим результатом. Однак, оскільки ви вже використовуєте ESM, я б рекомендував дотримуватися класичних модулів замість class
.
Якщо ваш модуль потребує лише одного екземпляра, ви можете пропустити зайві шари складності: експортуйте його публічні методи безпосередньо.
Як і було обіцяно на початку цієї глави, ми лише оглянули широку поверхню основних частин мови JS. Можливо, у вас все ще крутиться голова, адже це цілком природно після такої потоку інформації!
Навіть протягом цього «короткого» огляду JS ми висвітлили чи натякнули на тонну деталей, які ви повинні ретельно обдумати та переконатися, що добре їх розумієте. Я цілком серйозно рекомендую перечитати цю главу кілька разів.
У наступній главі ми збираємось набагато глибше вивчити деякі важливі аспекти роботи JS. Але перед тим, як глибше зануритися у кролячу нору, переконайтеся, що ви повністю засвоїли те, що ми щойно описали тут.