javascript

Как уменьшить боль от this в классах javascript

  • понедельник, 10 марта 2025 г. в 00:00:03
https://habr.com/ru/articles/889326/

Проблема

На службе была поставлена задача подготовить и передать клиенту js-библиотеку, которая состояла из несколько классов. Каких-то особых трудностей не ожидалось, поскольку библиотека использовалась в нашей конторе уже не один год и была тщательно оттестирована.

Я «причесал» код, перенёс захардкоженные значения и магические числа, которые неизбежно накапливаются в программе при выполнении «очень срочных и важных заданий», в аргументы методов и переменные классов, отредактировал и дополнил документирующие комментарии и уже собирался отправлять пакет клиенту.

Но одна вещь останавливала меня — часть методов ключевого класса для обращения к другим свойствам и функциям своего класса использовала this. Кайл Симпсон удачно назвал такие методы this-aware functions. И неправильный вызов этих функций мог создать проблемы для разработчиков клиента.

Как читатели наверняка хорошо знают, в javascript есть давняя и известная сложность (особенность) работы с this. А именно, мы достоверно не знаем, каким будет this при вызове метода класса. Мы можем только надеется, что это будет нужный нам объект. Всё зависит от контекста, в котором вызывается функция. Или, как ещё говорят, от объекта, на котором вызывается метод. Особенно это касается библиотечных функций, которые могут использоваться другими разработчиками в абсолютно неизвестном авторам контексте.

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

Во всём этом тексте термины «функция» и «метод» (класса) используются как полные синонимы, потому что они таковыми и являются в контексте рассматриваемой проблемы.

Как this может потерять контекст

Правильный контекст this может потеряться:

  1. во вложенных функциях;

  2. при использовании метода как функции обратного вызова (callback).

Потеря контекста во вложенных функциях

Проблема с вложенными методами обычно появляется в «своём» коде и решается с помощью:

  1. Приёма “self/that/context” и замыкания;

  2. Привязкой контекста с использованием стрелочных функций;

  3. Привязкой контекста с помощью bind().

Проблемный класс с вложенной функцией:

class ClassWithNestedFunctionExample {
    constructor(importantArgument) {
        this.importantArgument = importantArgument;
    }

    justInvokeFunction(func) {
        func();
    }

    doStrangeThing() {
        this.justInvokeFunction(function subfunction() {
            console.log(this.importantArgument);        // Здесь неправильный this
        });    
    }
}


const a = new ClassWithNestedFunctionExample(2);

// Здесь получаем ошибку 
// Uncaught TypeError: Cannot read properties of undefined (reading 'importantArgument')
a.doStrangeThing();  

Решение с помощью “self/that/context” и замыкания:

class ClassWithNestedFunctionExample {
    constructor(importantArgument) {
        this.importantArgument = importantArgument;
    }

    justInvokeFunction(func) {
        func();
    }

    doStrangeThing() {
        self = this;
        this.justInvokeFunction(function subfunction() {
            console.log(self.importantArgument);
        });    
    }
}

const a = new ClassWithNestedFunctionExample(2);

a.doStrangeThing();
// Покажет 2

Решение с помощью стрелочных функций (стрелочная функция использует контекст, в котором она определена, а не тот, в котором она вызывается):

class ClassWithNestedFunctionExample {
    constructor(importantArgument) {
        this.importantArgument = importantArgument;
    }

    justInvokeFunction(func) {
        func();
    }

    doStrangeThing() {
        this.justInvokeFunction(() => {
            console.log(this.importantArgument); // Здесь this относится к объекту класса
        });    
    }
}

const a = new ClassWithNestedFunctionExample(2);

a.doStrangeThing();
// Покажет 2

Решение с помощью bind() (явная привязка):

class ClassWithNestedFunctionExample {
    constructor(importantArgument) {
        this.importantArgument = importantArgument;
    }

    justInvokeFunction(func) {
        func();
    }

    doStrangeThing() {
        this.justInvokeFunction(function subfunction() {
            console.log(this.importantArgument);        // Здесь неправильный this
        }.bind(this));                                  // А здесь "привязываем" правильный this    
    }
}

const a = new ClassWithNestedFunctionExample(2);

a.doStrangeThing();
// Покажет 2

В нашем пакете использовался первый приём, и вложенные функции не были проблемой.

Потеря контекста при передаче метода класса как функции обратного вызова (callback-function)

А вот передача метода класса в качестве callback’а может происходить в «чужом» коде вне авторского контроля. И если разработчики клиента передадут метод класса в качестве функции обратного вызова, тогда может быть потерян правильный контекст для this.

Небольшой пример:

class JustClass {
    constructor(importantArgument) {
        this.importantArgument = importantArgument;
    }

    outputImportantArgument() {
        console.log(this.importantArgument);        // Здесь может быть неправильный this
    }
}

let justClass = new JustClass(2);

justClass.outputImportantArgument();    // здесь сработает

// Код ниже не сработает

// callback on DOM event
document.getElementById('elementId').addEventListener("click", justClass.outputImportantArgument);

// callback for timer
setTimeout(justClass.outputImportantArgument, 0);

// callback for custom function
run(justClass.outputImportantArgument);

function run(fn){
  fn();
} 

«Прикрепить» правильный контекст при передаче метода, как callback’а, можно с помощью bind:

// callback on DOM event
document.getElementById('elementId').addEventListener("click", justClass.outputImportantArgument.bind(justClass));

// callback for timer
setTimeout(justClass.outputImportantArgument.bind(justClass), 0);

// callback for custom function
run(justClass.outputImportantArgument.bind(justClass));

function run(fn){
  fn();
} 

Или с помощью создания функции-обёртки, которая вызовет наш метод в текущем контексте, «унесённом» с помощью замыкания, например, с помощью стрелочных функций:

// callback on DOM event
document.getElementById('elementId').addEventListener("click", 
    () => justClass.outputImportantArgument())

// callback for timer
setTimeout(() => justClass.outputImportantArgument(), 0);

// callback for custom function
run(() => justClass.outputImportantArgument());

function run(fn){
  fn();
} 

Если будет необходимо убрать обработчик события, то придётся создавать именованную версию функции-обёртки:

function outputImportantArgumentOnClick() {
    justClass.outputImportantArgument();
}
document.getElementById('elementId').addEventListener("click", outputImportantArgumentOnClick);

// Убираем callback когда будет нужно

document.getElementById('elementId').removeEventListener("click", outputImportantArgumentOnClick);

Обратите внимание, что во всех приведённых примерах дополнительные усилия по сохранению правильного контекста в callback’ах ложатся на программистов клиента.

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

Доложил ситуацию руководству, и оно дало время на то, чтобы сделать «всё как надо».

Возможные варианты решения

Отказаться от использования this

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

Например, вот так (из книги Дугласа Крокфорда «Как устроен javascript», глава 17):

function counter_constructor() {

    let counter = 0;

    function up() {    
        counter += 1;
        return counter;
    }

    function down() {
        counter -= 1;
        return counter;
    }

    return Object.freeze({
        up,
        down
    });

}

Здесь у возвращаемого объекта «открытые» методы up() и down(), которые имеют доступ к «приватной» переменной counter.

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

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

К тому же, есть и другие разработчики, которые используют классы в JS. И методы в их классах должны иметь доступ к другим методам и переменным этого класса через объект this. И хотелось найти правильный способ сделать методы более защищённым от ошибки потери контекста this.

«Жесткая привязка» this с помощью bind() в конструкторе

Можно было воспользоваться таким старым и проверенным приёмом, как жёсткая привязка нужных методов к контексту объекта в его конструкторе с помощью bind():

class OurWonderfulClass {
    x = null

    constructor(x) {
        this.x = x;
        this.thisAwareMethod = this.thisAwareMethod.bind(this);

    }
    thisAwareMethod() { return this.x * 2; }

}

Этот подход полностью решал проблему, но имел следующие недостатки:

  • создаётся дополнительная копия функции в экземпляре класса, а не в прототипе, теряется возможность наследования с помощью прототипирования;

  • затрудняется тестирование с помощью мокирования;

  • замедление работы (копия жёстко привязанного метода создаётся значительно медленнее, подробности см. здесь).

«Автопривязка» this с помощью стрелочных функций

Другим возможным способом было использования для определения this-aware методов стрелочных функций:

class OurWonderfulClass {
    x = null

    constructor(x) {
        this.x = x;
    }
    thisAwareMethod = () => this.x * 2

}

Самый простой и изящный вариант: не требуется писать дополнительный код и this в методе автоматически привязывается к объекту класса.

Но имелись и недостатки:

  • функции создаются в экземпляре класса, а не в прототипе, теряется возможность наследования с помощью прототипирования;

  • затрудняется тестирование с помощью мокирования;

  • замедление работы (прямо-таки невероятное падение скорости создания объектов класса, даже по сравнению с bind() в конструкторе, детали и результаты тестирования здесь).

Подробный анализ использования стрелочных функций в классах приведён в статьях Nicolas Charpentier и Angus Croll (на англ. языке).

На самом деле, указанные недостатки, особенно для варианта со стрелочными функциями, не являются серьёзными возражениями против их использования. Маловероятно, что будет создаваться множество экземпляров объектов наших классов и/или потребуется использование именно наследования через цепочку прототипов. А при наследовании класса методы, определённые с помощью стрелочных функций, прекрасно переносятся в дочерний класс.

Но хотелось совершенства: и скорость работы кода сохранить, и его надежность повысить, и создание подставных объектов обеспечить. Кроме того, использовать вышеописанные приемы, означало нарушить принцип YAGNI («тебе это не понадобится») и решать проблему, которой (пока ещё!) не существует.

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

Обдумывание велось с помощью ТРИЗ, медитации и других методов стимулирования творческого мышления.

Придуманное и использованное решение

А озарение пришло, как всегда, во время прогулки. Всё решилось просто и изящно.

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

И значит, мы, как авторы API, должны:

  1. Сообщить пользователям об особенности использования этих методов;

  2. Обеспечить «быстрое падение» методов, если им передан неправильный контекст.

Сказано — сделано.

Для всех методов, использующих this, в документирующие комментарии были добавлены соответствующее предупреждение для пользователей методов, а в самое начало всех методов, была вставлена проверка правильный ли объект олицетворяет this. Примерно так:

class OurWonderfulClass {

    /**
     * Метод thisAwareMethod делает очень нужные и важные вещи.
     *
     * Метод должен вызываться только на объекте своего класса,
     * при передаче метода в качестве функции обратного вызова
     * используйте "жесткую" привязку с помощью bind().
     *
     * @returns boolean
     */
    thisAwareMethod() {
        if (!(this instanceof OurWonderfulClass)) {
            throw new TypeError("Этот метод должен вызываться только на объекте OurWonderfulClass.");
        }

        // Какие-то другие выражения

        return true;
    }
}

На мой взгляд, выбранная реализация решила все возможные задачи:

  • сохранила скорость работы кода;

  • сохранила возможности наследования через прототипирование;

  • предупредила пользователей API о возможной проблеме;

  • обеспечила прекращение работы this-aware методов, если передан неправильный this.

После этого беспокойство отступило, пришло умиротворение, и пакет был передан клиенту.

P.S. Уже готовя материал к публикации на Хабре, встретил хороший текст с анализом контекста выполнения функций, в котором предлагается проводить похожую проверку на тип объекта this, только в функции-конструкторе. Что же, значит, правильной дорогой идём, товарищи!

Библиография

Глава 4 “This работает” из книги Кайла Симпсона «Объекты и классы» 2-е изд. (на английском)

Arrow Functions in Class Properties Might Not Be As Great As We Think by Nicolas Charpentier

Of Classes and Arrow Functions (a cautionary tale) by Angus Croll

What to do when “this” loses context by Cristian Salcescu (перевод этой статьи на Хабре)

Глава «Привязка контекста к функции» из учебника Ильи Кантора

this: контекст выполнения функций