Кожне завантаження PDF завершувалося помилкою ReferenceError. Стек викликів не давав жодної корисної інформації, а специфікація, якої дотримувався код, на папері виглядала цілком логічною. У ній було сказано перевірити прапорець функціональності (feature flag) і порівняти кожен регіон із половиною pageWidth. Вона описувала, що має статися. Але вона не описувала, звідки має братися pageWidth, і цього одного упущення було достатньо, щоб зупинити весь конвеєр.
Архітектурні документи добре пояснюють поведінку. Але вони часто жахливо пояснюють межі. Речення на кшталт «функція перевіряє x щодо pageWidth» — це не технічний контракт; це розповідь, яка приховує залежність за звичайною англійською мовою. Коли розробник читає це речення і пише функцію на рівні модуля, яка посилається на pageWidth за назвою, код виглядає правильним, бо він відповідає опису. Потім середовище виконання намагається знайти це ім'я, не знаходить нічого в області видимості та видає помилку.
Рефакторинг, який мав би бути простим
Я бачив саме таку модель під час рефакторингу збирання сторінок. Специфікація містила два, здавалося б, чітких вимоги:
- Перевірити
FEATURE_LAYOUT. - Порівняти кожен регіон із
pageWidth / 2.
Розробник сумлінно виконав інструкції. Він виніс утилітарну функцію на рівень модуля і вставив pageWidth безпосередньо в тіло функції, не вказавши її як параметр. У специфікації не було зазначено, що pageWidth має передаватися через список аргументів. Не було зазначено, що функція існує на рівні модуля, де pageWidth більше не була видимою. Було просто припущено, що виконавець неявно розуміє контекст виконання.
Результатом був ReferenceError при кожному завантаженні PDF. Оскільки змінна була відсутня в області видимості модуля, функція миттєво видавала помилку. Якби в специфікації було чітко визначено межі функції та її вхідні дані, розробник передав би pageWidth у функцію, і цей баг був би структурно неможливим. Натомість інструкція спрацювала як пастка, спонукаючи розробника звернутися до батьківського контексту, якого не існувало.
Чотири значення слова «використовує»
Глибша проблема полягає в тому, що прозі бракує системи типів. Коли в специфікації сказано «функція використовує X», це речення є неоднозначним щонайменше у чотирьох аспектах у сучасному JavaScript-коді:
- Функція отримує X як формальний параметр.
- Функція зчитує X із змінної на рівні модуля, оголошеної в тому самому файлі.
- Функція створює замикання (closure) над X із вкладеного батьківського контексту.
- Функція витягує X із більшого об'єкта, переданого їй.
Кожен із цих варіантів відповідає формулюванню специфікації. Кожен із них проходить статичний аналіз. Але лише один із них є правильним для конкретної межі, а неправильний вибір приховує припущення за цією межею так, що це непомітно для компілятора.
Розробники зазвичай обирають шлях найменшого опору в момент написання коду. Якщо pageWidth випадково опиняється в зовнішньому контексті, вони прочитають її звідти, замість того щоб змінювати сигнатуру функції. Замикання приховує залежність. Код працює при першому запуску, проходить тести та відправляється в продакшн. Через кілька тижнів хтось переносить цю саму функцію в інший файл для повторного використання або для покращення читабельності. Батьківський контекст зникає. Код ламається, і ця поломка виглядає як нова регресія, хоча першопричиною була початкова прихована залежність.
Web Workers стирають докази
Ця проблема стає справді підступною, щойно в архітектуру входять Web Workers. Коли всередині воркера виникає помилка, браузер видаляє найбільш необхідну вам інформацію.
Ось що відбувається насправді. Всередині воркера неперехоплений виняток викликає ErrorEvent. Якщо воркер передає цю помилку в головний потік, типовий патерн полягає в тому, щоб взяти рядок message і передати його через межу. Головний потік отримує цей рядок, створює з нього новий об'єкт Error і логує або повторно викидає його. У DevTools відображається реконструйована помилка всередині обробника повідомлень головного потоку. Оригінальне ім'я файлу, номер рядка та стек викликів відкидаються. Справжнє місце збою стає невидимим.
Тож коли відсутність pageWidth спричинила ReferenceError у воркері, головний потік повідомив лише текст "pageWidth is not defined" у тому місці, де оброблялося повідомлення. Сама функція на рівні модуля знаходилася в
