Использование modifyClass для изменения базового поведения

Для продвинутых тем и плагинов Discourse предлагает систему modifyClass. Она позволяет расширять и переопределять функциональность многих классов ядра на JavaScript.

Когда использовать modifyClass

modifyClass следует использовать только в крайнем случае, когда вашу кастомизацию невозможно реализовать через более стабильные API кастомизации Discourse (например, методы plugin-api, outlet-ы плагинов, трансформеры).

Код ядра может измениться в любой момент. Следовательно, кастомизации, сделанные через modifyClass, могут перестать работать в любой момент. При использовании этого API убедитесь, что у вас есть механизмы контроля для выявления таких проблем до того, как они попадут на продакшн-сайт. Например, вы можете добавить автоматизированные тесты к теме/плагину или использовать тестовый сайт (staging) для проверки входящих обновлений Discourse в контексте вашей темы/плагина.

Базовое использование

api.modifyClass можно использовать для изменения функций и свойств любого класса, доступного через резолвер Ember. Это включает маршруты, контроллеры, сервисы и компоненты Discourse.

modifyClass принимает два аргумента:

  • resolverName (строка) — формируется путем указания типа (например, component/controller/etc.), затем двоеточия, а затем имени файла класса (через дефис). Например: component:d-button, component:modal/login, controller:user, route:application и т.д.

  • callback (функция) — функция, которая принимает существующее определение класса и возвращает его расширенную версию.

Например, чтобы изменить действие click() для компонента d-button:

api.modifyClass(
  "component:d-button",
  (Superclass) =>
    class extends Superclass {
      @action
      click() {
        console.log("button was clicked");
        super.click();
      }
    }
);

Синтаксис class extends ... имитирует наследование в классах JS. В целом, здесь можно применять любой синтаксис и возможности, поддерживаемые наследуемыми классами. Это включает super, статические свойства/функции и многое другое.

Однако существуют некоторые ограничения. Система modifyClass обнаруживает изменения только в prototype класса на уровне JS. На практике это означает:

  • создание или изменение constructor() не поддерживается

    api.modifyClass(
      "component:foo",
      (Superclass) =>
        class extends Superclass {
          constructor() {
            // Это не поддерживается. Конструктор будет проигнорирован
          }
        }
    );
    
  • создание или изменение полей класса не поддерживается (хотя некоторые декорированные поля класса, такие как @tracked, могут использоваться)

    api.modifyClass(
      "component:foo",
      (Superclass) =>
        class extends Superclass {
          someField = "foo"; // НЕ ПОДДЕРЖИВАЕТСЯ — не копируйте
          @tracked someOtherField = "foo"; // Это допустимо
        }
    );
    
  • простые поля класса в исходной реализации нельзя переопределить никаким образом (хотя, как указано выше, поля @tracked могут быть переопределены другим полем @tracked)

    // Код ядра:
    class Foo extends Component {
      // Это поле ядра нельзя переопределить
      someField = "original";
    
      // Это отслеживаемое поле ядра можно переопределить, включив
      // `@tracked someTrackedField =` в вызов modifyClass
      @tracked someTrackedField = "original";
    }
    

Если вы обнаружите, что хотите сделать что-то из перечисленного, возможно, ваш случай использования лучше удовлетворить, создав PR для добавления новых API в ядро (например, outlet-ы плагинов, трансформеры или специфические API).

Обновление устаревшего синтаксиса

Ранее modifyClass вызывался с использованием синтаксиса объектных литералов, например:

// Устаревший синтаксис — не используйте
api.modifyClass("component:some-component", {
  someFunction() {
    const original = this._super();
    return original + " some change";
  }
  pluginId: "some-unique-id"
});

Этот синтаксис больше не рекомендуется и содержит известные ошибки (например, при переопределении геттеров или @actions). Любой код, использующий этот синтаксис, следует обновить до синтаксиса нативных классов, описанного выше. В целом, конвертация выполняется следующим образом:

  1. Удалите pluginId — он больше не требуется
  2. Обновите до современного синтаксиса нативных классов, описанного выше
  3. Протестируйте изменения

Устранение неполадок

Класс уже инициализирован

При использовании modifyClass в инициализаторе вы можете увидеть следующее предупреждение в консоли:

Attempted to modify "{name}", but it was already initialized earlier in the boot process

При разработке тем/плагинов эта ошибка обычно возникает по двум причинам:

  • Добавление lookup() вызвало ошибку

    Если вы вызываете lookup() для синглтона слишком рано в процессе загрузки, это приведет к сбою последующих вызовов modifyClass. В такой ситуации попробуйте перенести вызов lookup на более поздний этап. Например, измените код следующим образом:

    // Поиск сервиса в инициализаторе, затем использование во время выполнения (плохо!)
    export default apiInitializer((api) => {
      const composerService = api.container.lookup("service:composer");
      api.composerBeforeSave(async () => {
        composerService.doSomething();
      });
    });
    

    На:

    // «Точно в срок» поиск сервиса (хорошо!)
    export default apiInitializer((api) => {
      api.composerBeforeSave(async () => {
        const composerService = api.container.lookup("service:composer");
        composerService.doSomething();
      });
    });
    
  • Добавление нового modifyClass вызвало ошибку

    Если ошибка возникла из-за того, что ваша тема/плагин добавляет вызов modifyClass, вам нужно перенести его на более ранний этап загрузки. Это часто случается при переопределении методов в сервисах (например, topicTrackingState) и в моделях, которые инициализируются на раннем этапе загрузки приложения (например, model:user инициализируется для service:current-user).

    Перенос вызова modifyClass на более ранний этап обычно означает перемещение вызова в pre-initializer и настройку его запуска до инициализатора Discourse ‘inject-discourse-objects’. Например:

    // (plugin)/assets/javascripts/discourse/pre-initializers/extend-user-for-my-plugin.js
    // или
    // (theme)/javascripts/discourse/pre-initializers/extend-user-for-my-plugin.js
    
    import { withPluginApi } from "discourse/lib/plugin-api";
    
    export default {
      name: "extend-user-for-my-plugin",
      before: "inject-discourse-objects",
    
      initializeWithApi(api) {
        api.modifyClass("model:user", (Superclass) => class extends Superclass {
          myNewUserFunction() {
            return "hello world";
          },
        });
      },
    
      initialize() {
        withPluginApi(this.initializeWithApi);
      },
    };
    

    После такой модификации модель пользователя должна работать без предупреждений, а новый метод станет доступен в объекте currentUser.


Этот документ контролируется версионированием — предложите изменения на GitHub.

16 лайков

I presume it is impossible or at least unreliable to attempt use modifyClass (within the legal use cases mentioned above) within a plugin on another plugin’s Component in the same install?

Even if it is a plugin included in core (e.g. Chat or Poll)?

1 лайк

If both plugins are installed and enabled, it should work with no problems. If the target isn’t installed/enabled, you’ll get a warning in the console. But you can use the ignoreMissing parameter to silence that.

api.modifyClass(
  "component:some-component",
  (Superclass) => ...,
  { ignoreMissing: true }
);

Of course, standard modifyClass advice still applies: it should be a last resort, and it can break at any time so you should ensure your testing is good enough to identify issues quickly. Using transformers would be a much safer strategy.

3 лайка

So how does that work, it defers the application until all components from all plugins have been registered and loaded?

I think I have a case which doesn’t appear to work.

1 лайк

All ES6 modules (including components) are defined first, then we run pre-initializers, then we run regular initializers. So yeah, by the time any initializer runs, all components are resolvable.

Happy to take a look if you can share a snippet or branch :eyes:

4 лайка

My bad, you need to be careful you provide the full path!

e.g.:

api.modifyClass("component:chat/modal/create-channel", :white_check_mark:

neither:

api.modifyClass("component:create-channel", :cross_mark:

nor even

api.modifyClass("component:modal/create-channel", :cross_mark:

are enough!

5 лайков

The example for api.modifyClass in plugin-api.gjs still uses legacy syntax. Perhaps it requires updating?

4 лайка