08 · JavaScript/TypeScript для AQA
this, класи та ООП у тестовому коді
Зміст
Відкрий будь-який тестовий фреймворк на JS/TS — і побачиш класи: LoginPage, ApiClient, BaseTest, компоненти сторінки. Page Object, найпоширеніший патерн автоматизації, — це просто клас, а всередині класу все тримається на слові this. Тому питання «як визначається this?» і «чим bind відрізняється від call?» — не абстрактна теорія, а те, що ламає реальні тести й що майже гарантовано спитають на співбесіді на AQA.
Класична сцена: метод page object раптом падає з TypeError: Cannot read properties of undefined. Виглядає як баг застосунку або кривий локатор, а насправді метод відірвали від об'єкта, і this став undefined. Хто розуміє механізм this, класів і прототипів (prototype) під капотом — читає такі помилки за секунди, а не гадає годину. Ця глава — про мовний фундамент; сам патерн Page Object детально розбирає розділ «Автоматизація: стратегія».
Як визначається this
Головна ідея, яку більшість вивчає найдовше: this не визначається там, де функцію оголосили. Він визначається в момент виклику — за тим, як функцію викликали. Одна й та сама функція в різних викликах отримує різний this. Тому дивитися треба не на тіло функції, а на місце виклику (call-site).
Правил прив'язки (binding) рівно чотири (коли на один виклик претендує кілька, пріоритет такий: new → явна прив'язка → метод → простий виклик):
- Виклик як метод —
obj.method().thisдорівнює об'єкту зліва від крапки, тобтоobj. Це найчастіший випадок у page object:loginPage.open()→ всерединіopenthis— цеloginPage. - Простий виклик —
fn(), без об'єкта зліва. У строгому режимі (strict mode)thisдорівнюєundefined. Тіло класу й ES-модулі завжди строгі, тож у тестовому коді це майже завждиundefined, а не глобальний об'єкт. - Виклик з
new—new Fn(). Створюється новий порожній об'єкт, іthisвказує на нього. Саме тому в конструкторіthis.page = pageзаписує поле в новий екземпляр. - Явна прив'язка —
fn.call(obj),fn.apply(obj),fn.bind(obj).this— це те, що ти передав першим аргументом.
Окремий, п'ятий випадок — стрілочні функції (arrow functions). У них немає власного this: вони беруть його лексично, з місця оголошення, і назавжди. Стрілку не можна «перепризначити» через call чи bind — її this зафіксований тим оточенням, де вона написана. Це не виняток заради винятку, а головний робочий інструмент проти втрати контексту (детальніше про лексичну область видимості — у главі «Функції, замикання та область видимості»).
Втрата контексту і bind/call/apply
Тепер найпоширеніша пастка. this прив'язується під час виклику, а не оголошення — тож щойно ти відриваєш метод від об'єкта й передаєш його як значення, зв'язок губиться.
class Toolbar {
label = 'Menu';
logLabel() {
console.log(this.label);
}
}
const bar = new Toolbar();
bar.logLabel(); // 'Menu' — виклик через крапку, this = bar
const detached = bar.logLabel;
detached(); // TypeError: Cannot read properties of undefined (reading 'label')
detached — це та сама функція, але виклик уже простий, без об'єкта зліва. this став undefined, і звернення this.label падає. У реальному коді це стається, коли метод передають як колбек: обробник події, аргумент у setTimeout, array.forEach(this.check), підписка на емітер. Функцію викличуть, але вже не «через крапку».
Рятують три методи, які є в кожної функції:
bind(thisArg)— не викликає функцію, а повертає нову функцію з назавжди прив'язанимthis. Її потім можна передавати куди завгодно.call(thisArg, arg1, arg2)— викликає функцію негайно з заданимthis, аргументи перелічуються через кому.apply(thisArg, [arg1, arg2])— те саме, щоcall, але аргументи одним масивом.
const bound = bar.logLabel.bind(bar);
bound(); // 'Menu' — this зафіксовано на bar
bar.logLabel.call(bar); // 'Menu' — виклик негайно
Мнемоніка різниці: call → comma (кома), apply → array (масив), bind → пізніше.
Але в сучасному коді bind руками пишуть рідко. Той самий ефект дає стрілковий метод як поле класу: його this лексично прив'язаний до екземпляра, тож метод можна відривати й передавати безкарно.
class Toolbar {
label = 'Menu';
logLabel = () => {
console.log(this.label); // this завжди — екземпляр
};
}
const bar = new Toolbar();
const detached = bar.logLabel;
detached(); // 'Menu' — контекст не губиться
Нюанс, за який чіпляються на співбесіді: такий стрілковий метод — це вже не спільний метод на прототипі, а окрема копія-поле в кожному екземплярі. Для десятка page object це неважливо; для тисяч об'єктів у гарячому циклі — вже аргумент. Знати різницю варто (див. розділ «Прототипи під капотом» нижче).
Класи: constructor, методи, static
Клас (class) — це шаблон об'єктів: він описує, які в них поля й методи. constructor — спеціальний метод, який виконується під час new і зазвичай приймає залежності. У тестовому коді конструктор page object майже завжди отримує page (об'єкт сторінки Playwright) і зберігає його в поле.
import { Page } from '@playwright/test';
class LoginPage {
constructor(private readonly page: Page) {}
async open(): Promise<void> {
await this.page.goto('/login');
}
async loginAs(user: string, password: string): Promise<void> {
await this.page.getByLabel('Email').fill(user);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
}
Запис private readonly page: Page у параметрах конструктора — це «параметр-властивість» TypeScript: короткий спосіб оголосити поле й одразу присвоїти йому аргумент. Звичайні методи (open, loginAs) — це дії сторінки, і всередині них this — конкретний екземпляр LoginPage.
Ключове слово static робить член належним самому класу, а не екземпляру. Статику не видно через this об'єкта — до неї звертаються через ім'я класу. Типове застосування — фабричні методи, які інкапсулюють складне створення об'єкта, і константи.
class ApiClient {
private constructor(private readonly token: string) {}
// Фабрика: залогінитись і повернути готового клієнта
static async authorized(request: APIRequestContext): Promise<ApiClient> {
const token = await requestToken(request);
return new ApiClient(token);
}
}
const client = await ApiClient.authorized(request);
Приватний конструктор плюс статична фабрика — акуратний спосіб гарантувати, що клієнта не створять без токена.
extends і super: успадкування з мірою
extends створює підклас, який успадковує поля й методи батька. super() у конструкторі викликає конструктор батька, а super.method() — батьківський метод. Правило, яке ловить новачків: у конструкторі підкласу до super() не можна звертатися до this — це кине ReferenceError. Спершу побудуй батьківську частину, потім користуйся this.
class BasePage {
constructor(protected readonly page: Page) {}
async open(path: string): Promise<void> {
await this.page.goto(path);
}
}
class LoginPage extends BasePage {
async open(): Promise<void> {
await super.open('/login'); // виклик батьківського open
}
}
Синтаксис — простий. Складне питання — коли ним користуватися. Тут діє канон «композиція проти успадкування (composition over inheritance)» з розділу «Автоматизація: стратегія». Коротко: неглибокий BasePage зі справді спільною механікою (базовий URL, спільне очікування) — норма. А от глибокі ієрархії (BasePage → AuthPage → AdminPage → UsersPage) швидко стають болем: зміна в корені ламає гілки, а логіка розмазана рівнями, і незрозуміло, звідки метод.
Тому для перевикористання частіше беруть композицію: has-a замість is-a. Сторінка не успадковує хедер і таблицю — вона містить їх як окремі об'єкти-компоненти в полях.
class DashboardPage {
readonly header: HeaderComponent;
readonly grid: DataGrid;
constructor(private readonly page: Page) {
this.header = new HeaderComponent(page);
this.grid = new DataGrid(page);
}
}
// у тесті:
await dashboard.header.logout();
await dashboard.grid.sortBy('Name');
HeaderComponent і DataGrid — самостійні класи, які можна перевикористати на будь-якій сторінці з хедером чи таблицею, не будуючи ієрархію. Успадкування прив'язує намертво; композицію легко перескладати.
Геттери, сеттери і приватні поля
Геттер (getter) і сеттер (setter) — методи, оголошені через get/set, до яких звертаються як до звичайних властивостей, без дужок. Ззовні cart.size виглядає як поле, а насправді щоразу виконується функція. Зручно для обчислюваних або похідних значень.
class Cart {
#items: string[] = [];
add(item: string): void {
this.#items.push(item);
}
get size(): number {
return this.#items.length; // читаємо як cart.size, без дужок
}
}
const cart = new Cart();
cart.add('book');
console.log(cart.size); // 1
Поле з решіткою — #items — це приватне поле (private field), справжня інкапсуляція на рівні мови. Звернутися до #items можна лише зсередини класу; будь-яка спроба ззовні — синтаксична помилка ще до запуску. Це критична відмінність від модифікатора private у TypeScript: private існує тільки під час компіляції, у зібраному JS поле лишається звичайним і доступним через obj['field']. Тобто private — це підказка компілятора й дисципліна, а # — жорсткий бар'єр у рантаймі. Для тестового коду частіше достатньо private з TypeScript (докладніше про його типи — у главі «TypeScript: типи, інтерфейси та строгий режим»); # беруть там, де приватність треба гарантувати самою мовою.
Прототипи під капотом
Класи в JavaScript великою мірою — синтаксичний цукор над прототипами (хоча додають і власну семантику: приватні поля #, строгий режим за замовчуванням, заборону виклику без new). Розуміти механізм варто, бо він пояснює і instanceof, і те, чому методи «спільні», і звідки взагалі береться метод, якого немає в самому об'єкті.
У кожного об'єкта є прихований зв'язок — [[Prototype]] — на інший об'єкт, його прототип. Коли ти звертаєшся до obj.method(), рушій шукає method спочатку в самому obj; не знайшовши — йде по цьому зв'язку вгору, до прототипу, потім до прототипу прототипу, аж поки не знайде або не впреться в null. Це і є ланцюжок прототипів (prototype chain).
Коли ти пишеш class LoginPage, звичайні методи (open, loginAs) кладуться не в кожен екземпляр, а один раз — у LoginPage.prototype. Усі екземпляри LoginPage посилаються на цей один об'єкт як на свій прототип. Тому метод у пам'яті один, а не копіюється в кожен об'єкт — на відміну від стрілкових полів-методів, які таки лежать у кожному екземплярі.
extends просто зшиває два такі ланцюжки: прототип LoginPage отримує своїм прототипом BasePage.prototype. Тому виклик успадкованого методу — це знахідка на другому кроці підйому ланцюжком. І loginPage instanceof BasePage повертає true саме тому, що BasePage.prototype трапляється десь у ланцюжку.
Мовний фундамент Page Object
Складемо все докупи. Page Object — це не магія фреймворку, а звичайний клас, який тримається рівно на тому, що ми розібрали:
- клас і
constructorприймаютьpageяк залежність і зберігають у поле; thisусередині методів указує на екземпляр сторінки — поки метод викликають через крапку;- методи на прототипі — це дії сторінки, спільні для всіх екземплярів;
- композиція компонентів (хедер, таблиця, модалка окремими об'єктами в полях) масштабує структуру краще за глибоке успадкування;
- інкапсуляція (
private,#) ховає локатори всередині, лишаючи назовні лише осмислені дії.
Тобто, щойно ти зрозумів this, класи й прототипи, Page Object читається як проста конструкція, а не як заклинання. Сам патерн — його межі, «god object», перевірки в page object — це тема розділу «Автоматизація: стратегія»; тут — його мова.
Типові помилки
this is undefinedпри виклику методу. Виглядає як баг класу чи зламаний локатор, а насправді метод відірвали від об'єкта (передали як колбек, уsetTimeout,forEach) — іthisставundefined. Лікування: стрілковий метод-поле абоbind.- «Стрілковий метод безкоштовно чинить
this». Виглядає як безкоштовний фікс, а насправді такий метод — окреме поле в кожному екземплярі, а не спільний метод на прототипі. Для page object байдуже, але на питання «де живе метод?» це правильна відповідь. - «TS
privateприховав поле». Виглядає як справжня інкапсуляція, а насправдіprivateзникає при компіляції — у рантаймі поле доступне. Гарантовану приватність дає лише#. - Звернення до
thisдоsuper(). Виглядає як дивнийReferenceErrorна порожньому місці, а насправді в конструкторі підкласу до викликуsuper()thisще не існує. - Глибока ієрархія
extendsзаради перевикористання. Виглядає як економія коду, а насправді жорстка зв'язка: зміна в корені ламає всі гілки. Часто правильна відповідь — композиція.
Підсумок
thisвизначається в момент виклику за тим, як викликали функцію, а не де її оголосили; стрілочні функції — виняток, вони берутьthisлексично.- Метод, відірваний від об'єкта, губить
this; повертають контекстbind(нова функція),call/apply(виклик негайно) або стрілковий метод-поле. staticналежить класу, а не екземпляру;#field— справжня приватність у рантаймі, тоді як TypeScriptprivateіснує лише під час компіляції.- Класи — цукор над прототипами: звичайні методи лежать на прототипі (спільні для всіх екземплярів),
extendsзшиває ланцюжки прототипів,instanceofперевіряє цей ланцюжок. - Page Object — це клас, і для перевикористання композиція компонентів зазвичай краща за глибоке успадкування.
Що питають на співбесіді
- «Як визначається
thisу JavaScript?» Інтерв'юер хоче почути головне:thisзалежить від місця виклику, а не оголошення. Сильна відповідь перелічує чотири правила (метод, простий виклик,new,call/bind) і окремо — лексичнийthisстрілок. - «Чим відрізняються
call,applyіbind?» Дивляться, чи розумієш, щоbindповертає нову функцію, аcall/applyвикликають негайно, і що різниця міжcallіapply— у форматі аргументів. - «Чому метод падає з
undefined, коли його передали в колбек?» Класична перевірка на розуміння втрати контексту. Плюс — назвати два способи це виправити. privateу TypeScript проти#. Перевіряють, чи знаєш, що TS-приватність — це лише перевірка компілятора, а#тримається в рантаймі.- «Класи — це справжнє ООП чи цукор?» Хочуть почути про прототипи: методи на
prototype, ланцюжок пошуку, як працюєextendsіinstanceof. - «Композиція чи успадкування для page object?» Оцінюють інженерну зрілість: чи не тягнеш глибоку ієрархію туди, де достатньо композиції компонентів.
Джерела
Від чого залежить значення this у JavaScript?
this — це не фіксоване посилання, прив'язане до тіла функції, а значення, яке підставляється щоразу під час виклику. Ключова ідея: важливо не де функцію написали, а як її покликали — тобто місце виклику (call-site). Одну й ту саму функцію можна викликати п'ятьма різними способами й отримати п'ять різних this. Тому, читаючи код, дивись не на оголошення, а на рядок, де стоять дужки виклику. Для AQA це не абстракція: саме тут ховається половина падінь методів page object.
Перелічи правила прив'язки this та їхній пріоритет.
Основних правил чотири. Виклик як метод (obj.method()) дає this, що дорівнює об'єкту зліва від крапки. Простий виклик (fn()) у строгому режимі дає undefined. Виклик через new створює новий об'єкт і робить його значенням this. Явна прив'язка (call, apply, bind) підставляє те, що передали першим аргументом. Коли на один виклик претендує кілька правил, старшинство таке: new перемагає явну прив'язку, та перемагає метод, а метод перемагає простий виклик. Стрілочні функції стоять осторонь цієї ієрархії — вони взагалі не мають власного this.
Чому стрілочні функції поводяться з this інакше?
Стрілка не заводить власного this — вона підхоплює його лексично, з того оточення, де її написали, і більше ніколи не змінює. Це означає, що жоден call, apply чи bind не здатен перепризначити this стрілки: спроба просто ігнорується. Наслідок практичний: стрілочна функція запам'ятовує контекст назавжди в момент оголошення, тому її безпечно передавати як колбек. Саме тому стрілковий метод-поле в класі — головний робочий спосіб не загубити прив'язку до екземпляра. Це не примха мови, а інструмент проти найпоширенішої пастки з this.
Метод page object падає з TypeError: Cannot read properties of undefined. Що це зазвичай означає?
У переважній більшості випадків метод відірвали від свого об'єкта, і всередині нього this став undefined. Виглядає як баг застосунку чи зламаний локатор, а насправді функцію передали кудись як значення — у колбек обробника події, аргументом у setTimeout, у forEach — і викликали вже без крапки й об'єкта зліва. Прив'язка this вирішується під час виклику, тож щойно зникає obj. перед викликом, зникає й контекст. Звернення на кшталт this.page після цього падає, бо undefined не має полів. Перед заведенням дефекту тут варто перевірити не продукт, а те, як саме викликали метод.
Чим відрізняються call, apply і bind?
Усі три задають this явно, але по-різному щодо моменту й формату. call викликає функцію негайно, а додаткові аргументи перелічують через кому. apply теж викликає негайно, але аргументи передають одним масивом. bind нічого не викликає — він повертає нову функцію з навіки зафіксованим this, яку можна зберегти й покликати пізніше. Мнемоніка проста: call — comma, apply — array, bind — на потім. У тестовому коді bind руками пишуть нечасто, бо той самий ефект дає стрілковий метод-поле.
Як повернути this методу, який передають у колбек?
Є два робочі способи. Перший — обгорнути метод у bind: obj.method.bind(obj) повертає нову функцію із зафіксованим контекстом, і її вже можна безпечно передавати. Другий, сучасніший — оголосити метод як стрілкове поле класу (method = () => { ... }), тоді його this лексично прив'язаний до екземпляра й не губиться при відриві. Обидва рішення прибирають TypeError. У сучасному тестовому коді частіше беруть стрілкове поле, бо воно читабельніше й не потребує пам'ятати про прив'язку на кожному колбеку.
Стрілковий метод-поле й звичайний метод класу — де вони живуть у пам'яті?
Це та відмінність, за яку люблять зачепитися на співбесіді. Звичайний метод кладеться один раз у прототип класу, і всі екземпляри посилаються на цю єдину копію. Стрілкове поле-метод натомість створюється заново в кожному екземплярі — це вже не спільний метод на прототипі, а окреме поле об'єкта. Для десятка page object різниця несуттєва, і зручність стрілки переважує. Але якщо об'єктів тисячі й вони живуть у гарячому циклі, кожна зайва копія методу — це витрата пам'яті, і тут аргумент уже на боці звичайного методу з bind.
Що таке constructor і що зазвичай отримує конструктор page object?
constructor — спеціальний метод, який автоматично спрацьовує під час new і готує свіжий екземпляр до роботи. Його типова робота — прийняти залежності й розкласти їх по полях. У page object конструктор майже завжди приймає об'єкт сторінки Playwright (page) і зберігає його, щоб методи потім могли ним користуватися. У TypeScript це часто записують коротко через параметр-властивість (private readonly page: Page прямо в дужках конструктора) — це оголошує поле й одразу присвоює йому аргумент. Усередині конструктора this — це новостворений об'єкт, який new щойно виготовив.
Навіщо потрібне ключове слово static і статичні фабричні методи?
static робить член належним самому класу, а не його екземплярам — до нього звертаються через ім'я класу, а не через this об'єкта. Типове застосування — константи й фабричні методи, які інкапсулюють складне створення об'єкта. Наприклад, статичний метод може спершу залогінитися, дістати токен, а вже потім повернути готовий екземпляр клієнта. У парі з приватним конструктором це дає гарантію, що об'єкт не створять в недобудованому стані — єдиний легальний шлях створення проходить через фабрику. Для API-клієнтів у тестах це акуратний спосіб не дозволити зробити клієнта без авторизації.
Що роблять extends і super і чому не можна чіпати this до super()?
extends створює підклас, який успадковує поля й методи батьківського класу. super() у конструкторі викликає конструктор батька, а super.method() дає доступ до батьківської версії перевизначеного методу. Правило, на якому ловлять новачків: у конструкторі підкласу до виклику super() звертатися до this заборонено — це кине ReferenceError. Причина в тому, що екземпляр ще фізично не побудований, поки батьківський конструктор не відпрацював. Тобто спершу будуємо батьківську частину об'єкта через super(), і лише потім this існує й ним можна користуватися.
Композиція чи успадкування для page object — що обрати?
За замовчуванням варто схилятися до композиції, а глибокі ієрархії extends тримати під підозрою. Неглибокий базовий клас зі справді спільною механікою (базовий URL, спільне очікування) — нормальна річ. Проблеми починаються з глибоких ланцюгів на кшталт базова → авторизована → адмінська → сторінка користувачів: зміна в корені ламає всі гілки, а логіка розмазана рівнями, і незрозуміло, звідки взявся метод. Композиція розв'язує це через has-a замість is-a: сторінка не успадковує хедер і таблицю, а містить їх окремими об'єктами-компонентами в полях. Такі компоненти легко перевикористати будь-де, тоді як успадкування прив'язує намертво.
Що таке геттери й сеттери і навіщо вони в класі?
Геттер (getter, оголошується через get) і сеттер (setter, через set) — це методи, до яких звертаються як до звичайних властивостей, без дужок виклику. Зовні cart.size виглядає як просте поле, а насправді щоразу під капотом виконується функція. Це зручно для обчислюваних або похідних значень, які не хочеться зберігати окремо й тримати в синхронізації. Геттер може, наприклад, повертати довжину прихованого приватного масиву, а сеттер — валідувати значення перед присвоєнням. Синтаксично це виглядає як звичайне поле, тому користувач класу навіть не помічає, що працює логіка.
Чим #-приватне поле відрізняється від private у TypeScript?
Різниця в тому, коли саме діє приватність. #field — це справжня інкапсуляція на рівні мови: до нього не можна звернутися ззовні класу навіть у зібраному JavaScript, будь-яка спроба — синтаксична помилка ще до запуску. Модифікатор private з TypeScript існує лише під час компіляції: він допомагає редактору й компілятору, але в скомпільованому JS поле лишається звичайним і доступним, наприклад через obj['field']. Тобто private — це домовленість і підказка інструменту, а # — жорсткий бар'єр у рантаймі. Для більшості тестового коду достатньо private; # беруть там, де приватність треба гарантувати самою мовою, а не дисципліною.
Що таке прототип і ланцюжок прототипів?
Кожен об'єкт у JavaScript має прихований зв'язок ([[Prototype]]) на інший об'єкт — свій прототип. Коли ти звертаєшся до властивості чи методу, рушій шукає його спершу в самому об'єкті; не знайшовши — йде по цьому зв'язку вгору, до прототипу, потім до прототипу прототипу, і так аж до null. Ця послідовність об'єктів і є ланцюжок прототипів (prototype chain). Саме він пояснює, звідки береться метод, якого в самому об'єкті немає — його знайшли на одному з верхніх рівнів. Розуміння цього механізму знімає магію і з instanceof, і з успадкування.
Де насправді живуть методи класу — у кожному екземплярі чи спільно?
Звичайні методи класу лежать не в кожному екземплярі, а один раз — у КласName.prototype. Усі створені об'єкти посилаються на цей єдиний об'єкт як на свій прототип і беруть методи звідти через ланцюжок пошуку. Тому в пам'яті метод один, скільки б екземплярів ти не наплодив — його не копіюють у кожен об'єкт. Виняток — стрілкові поля-методи: вони таки лежать окремою копією в кожному екземплярі, бо це поля, а не методи прототипу. Ця відмінність — прямий наслідок того, як класи влаштовані поверх прототипів.
Класи в JavaScript — це справжнє ООП чи синтаксичний цукор?
Значною мірою це цукор над прототипами: за фасадом class стоїть той самий прототипний механізм, що й раніше. Звичайні методи лягають на прототип, extends просто зшиває два прототипні ланцюжки, а instanceof перевіряє, чи трапляється потрібний прототип у ланцюжку об'єкта. Водночас класи додають і власну семантику, якої немає в голих прототипах: приватні поля #, строгий режим у тілі за замовчуванням, заборону викликати клас без new. Тому чесна відповідь — це цукор, але не порожній: синтаксис зручніший і має кілька справжніх нових можливостей. Сильний кандидат згадає і механізм прототипів, і ці додаткові речі.
Як працює instanceof?
instanceof не порівнює типи як рядки — він перевіряє ланцюжок прототипів. Вираз obj instanceof Клас повертає true, якщо Клас.prototype трапляється десь у ланцюжку прототипів об'єкта. Тому для підкласу перевірка проти батьківського класу теж дає true: extends вставив прототип батька в ланцюжок, і instanceof його там знаходить. Це прямий наслідок того, як влаштоване успадкування через прототипи. Знання цього механізму пояснює, чому одна перевірка instanceof може бути правдивою одразу для кількох класів у ієрархії.
Чому кажуть, що Page Object — це «просто клас»?
Бо коли розібратися в this, класах і прототипах, у Page Object не лишається магії. Клас із конструктором приймає page залежністю й кладе в поле; this усередині методів указує на екземпляр сторінки, поки метод кличуть через крапку; методи-дії лежать на прототипі спільно для всіх екземплярів; композиція компонентів масштабує структуру краще за глибоке успадкування; а private чи # ховають локатори всередині, лишаючи назовні лише осмислені дії. Тобто патерн тримається рівно на мовних механізмах, які ми розібрали, і читається як звичайна конструкція. Межі самого патерну (god object, перевірки всередині page object) — це вже окрема інженерна тема, але його мова — це this і класи.
Три ситуації, у яких this, класи й прототипи вирішують, зелений тест чи години гадань: як метод page object губить контекст у колбеку й два способи це полагодити, як прочитати this за місцем виклику через таблицю рішень, і як глибоку ієрархію extends перескласти на композицію компонентів. Скрізь — що дивитися й чому.
Кейс 1. Метод page object губить this у колбеку
Класична сцена: метод сторінки раптом падає з TypeError, і виглядає це як баг застосунку. Насправді метод відірвали від об'єкта, і this став undefined. Ось спрощений відбиток проблеми — метод передали як значення в forEach:
class SearchPage {
private readonly results: string[] = [];
constructor(private readonly page: Page) {}
collect(title: string): void {
this.results.push(title); // this потрібен на екземпляр
}
async collectAll(titles: string[]): Promise<void> {
// метод відірвали від об'єкта — this всередині стане undefined
titles.forEach(this.collect);
// TypeError: Cannot read properties of undefined (reading 'results')
}
}
forEach викликає передану функцію просто як fn(item), без об'єкта зліва від крапки. Прив'язка this вирішується під час виклику, тож this.collect — це вже гола функція без контексту, і всередині this дорівнює undefined.
Спосіб перший — зберегти прив'язку явно, через стрілку на місці виклику або bind:
titles.forEach((title) => this.collect(title)); // стрілка бере this лексично
titles.forEach(this.collect.bind(this)); // bind фіксує контекст
Спосіб другий, який прибирає проблему в корені, — оголосити сам метод як стрілкове поле класу. Тоді його this навіки прив'язаний до екземпляра, і метод можна відривати й передавати куди завгодно:
class SearchPage {
private readonly results: string[] = [];
collect = (title: string): void => {
this.results.push(title); // this завжди — екземпляр, хай як викликають
};
}
Що дивитися й чому:
- Симптом оманливий.
TypeError: Cannot read properties of undefinedвиглядає як зламаний локатор чи падіння продукту, а насправді питання лише в тому, як покликали метод. Перш ніж заводити дефект — перевір call-site. - Стрілка на місці виклику vs стрілкове поле. Обгортка
(title) => this.collect(title)лагодить один конкретний виклик; стрілкове поле лагодить метод раз і назавжди, тож для колбек-важких класів воно зручніше. - У стрілкового поля є ціна. Це вже не спільний метод на прототипі, а окрема копія в кожному екземплярі. Для десятка page object байдуже, але саме про це можуть спитати на співбесіді: «де живе цей метод?».
Кейс 2. Таблиця рішень: як прочитати this за місцем виклику
Коли метод поводиться дивно, не вгадуй — прочитай this за одним рядком, де стоять дужки виклику. Порядок перевірки згори вниз, перше збіжне правило виграє:
| Що бачиш на місці виклику | Значення this | Приклад |
|---|---|---|
| Стрілочна функція | З оточення, де оголошено (лексично) | arr.map(x => this.check(x)) |
fn.call(o) / fn.apply(o) / fn.bind(o) | Об'єкт, який передали | open.call(loginPage) |
new Fn() | Новий створений об'єкт | new LoginPage(page) |
| Є об'єкт зліва від крапки | Той об'єкт | loginPage.open() → loginPage |
| Простий виклик, нічого зліва | undefined (строгий режим) | const f = obj.m; f() |
Кілька коротких перевірок, щоб таблиця осіла:
class Toolbar {
label = 'Menu';
show() { return this.label; }
}
const bar = new Toolbar();
bar.show(); // 'Menu' — об'єкт зліва від крапки
const detached = bar.show;
detached(); // впаде — простий виклик, this = undefined
bar.show.call({ label: 'X' }); // 'X' — явна прив'язка перемагає метод
Що дивитися й чому:
- Дивись на виклик, не на оголошення. Одна й та сама
showдає то'Menu', то падіння, то'X'— усе вирішує рядок виклику, а не тіло функції. - Пріоритет має значення. У
bar.show.call(...)є і крапка, іcall; виграє явна прив'язка, бо вона старша за виклик-метод.newстарший за все, крім лексичногоthisстрілки. - Строгий режим — стан за замовчуванням. Тіло класу й ES-модулі завжди строгі, тож простий виклик дає
undefined, а не глобальний об'єкт — і саме тому падає, а не тихо працює з чужим контекстом.
Кейс 3. Від глибокого extends до композиції компонентів
Спокуса перевикористати код через успадкування швидко породжує ланцюг на кшталт базова сторінка → авторизована → адмінська → сторінка користувачів. Спершу зручно, а далі — зміна в корені ламає всі гілки, і незрозуміло, з якого рівня взявся метод. Ось як така сторінка виглядає до рефакторингу:
// крихка ієрархія: логіка розмазана рівнями
class BasePage { /* goto, waitForReady */ }
class AuthPage extends BasePage { /* logout у хедері */ }
class AdminPage extends AuthPage { /* сортування таблиці */ }
class UsersPage extends AdminPage { /* дії саме над користувачами */ }
Проблема не в синтаксисі, а в жорсткій зв'язці is-a: UsersPage тягне за собою всі рівні, і хедер із таблицею неможливо перевикористати на іншій сторінці, не потягнувши всю гілку. Композиція розв'язує це через has-a — сторінка не успадковує хедер і таблицю, а містить їх окремими об'єктами в полях:
class HeaderComponent {
constructor(private readonly page: Page) {}
async logout(): Promise<void> { /* дії хедера */ }
}
class DataGrid {
constructor(private readonly page: Page) {}
async sortBy(column: string): Promise<void> { /* дії таблиці */ }
}
class UsersPage {
readonly header: HeaderComponent;
readonly grid: DataGrid;
constructor(private readonly page: Page) {
this.header = new HeaderComponent(page);
this.grid = new DataGrid(page);
}
}
// у тесті читається прозоро, видно звідки дія:
await usersPage.header.logout();
await usersPage.grid.sortBy('Name');
Що дивитися й чому:
has-aзамістьis-a.HeaderComponentіDataGrid— самостійні класи, які кладуть на будь-яку сторінку з хедером чи таблицею, не будуючи ієрархію. Успадкування прив'язує намертво, композицію легко перескладати.- Неглибокий базовий клас — це ще норма. Композиція проти успадкування не означає «ніколи не успадковуй»: один тонкий шар зі справді спільною механікою (базовий URL, спільне очікування) прийнятний. Болять саме глибокі ланцюги.
- Видно, звідки метод. У
usersPage.grid.sortBy(...)одразу читається, що сортування — робота таблиці, а не загадкового предка за три рівні вгору. Це прямий виграш у читабельності тесту.
Як визначається this
- Розумію, що
thisпідставляється в момент виклику за тим, як функцію покликали, а не там, де її оголосили. - Можу назвати чотири правила прив'язки (метод через крапку, простий виклик,
new, явна) та їхній пріоритет:new→ явна → метод → простий виклик. - Пам'ятаю, що при простому виклику в строгому режимі
thisдорівнюєundefined, а тіло класу й модулі завжди строгі. - Можу пояснити, чому стрілочна функція бере
thisлексично й чомуcall/bindїї не перепризначують.
Втрата контексту і bind/call/apply
- Розумію, чому відірваний від об'єкта метод губить
thisі падає зTypeError: Cannot read properties of undefined. - Знаю типові місця втрати контексту: колбек обробника,
setTimeout,forEach, підписка на емітер. - Можу пояснити різницю
call(аргументи через кому, виклик негайно),apply(аргументи масивом),bind(нова функція на потім). - Знаю два способи повернути контекст:
bindабо стрілкове поле-метод, і чому в сучасному коді частіше беруть друге. - Розумію, що стрілкове поле-метод — окрема копія в кожному екземплярі, а не спільний метод на прототипі.
Класи: constructor, static, extends
- Можу пояснити, що робить
constructorпід часnew, чому page object приймає нимpage, і що таке параметр-властивість (private readonly page: Pageу дужках конструктора). - Розумію, що
staticналежить класу, а не екземпляру, і навіщо статичні фабрики з приватним конструктором. - Знаю правило: у конструкторі підкласу до
super()не можна звертатися доthis— будеReferenceError. - Можу пояснити, чому для page object композиція компонентів зазвичай краща за глибоке успадкування
extends.
Інкапсуляція: геттери, сеттери, приватні поля
- Розумію, що геттер/сеттер (
get/set) викликають без дужок, як звичайне поле, а насправді виконується функція. - Можу пояснити різницю
#fieldvsprivate: перший — бар'єр у рантаймі, другий зникає при компіляції (у зібраному JS поле доступне черезobj['field']). - Розумію, коли достатньо
private, а коли приватність треба гарантувати самою мовою через#.
Прототипи під капотом
- Можу пояснити, що таке
[[Prototype]]і ланцюжок прототипів (пошук властивості вгору доnull). - Знаю, що звичайні методи лежать один раз на
prototype, спільні для всіх екземплярів. - Розумію, що класи — цукор над прототипами, який додає
#, строгий режим і заборону виклику безnew. - Можу пояснити, як
extendsзшиває ланцюжки прототипів і чомуinstanceofперевіряє саме цей ланцюжок.
Від чого залежить значення this у звичайній (не стрілочній) функції?
Питання
Від чого залежить this у звичайній функції?