modifyClass를 사용하여 핵심 동작 변경

고급 테마와 플러그인의 경우, Discourse는 modifyClass 시스템을 제공합니다. 이를 통해 코어 JavaScript 클래스의 많은 기능을 확장하고 오버라이드할 수 있습니다.

modifyClass를 사용할 때

modifyClass는 Discourse의 보다 안정적인 커스터마이징 API(예: plugin-api 메서드, plugin outlets, transformers)를 통해 커스터마이징을 할 수 없는 경우를 제외하고는 최후의 수단으로 사용해야 합니다.

코어의 코드는 언제든지 변경될 수 있습니다. 따라서 modifyClass를 통해 만든 커스터마이징도 언제든지 깨질 수 있습니다. 이 API를 사용할 때는 프로덕션 사이트에 문제가 도달하기 전에 이를 포착할 수 있는 제어가 이루어지도록 해야 합니다. 예를 들어, 테마/플러그인에 자동화된 테스트를 추가하거나, 스테이징 사이트를 사용하여 Discourse 업데이트를 테마/플러그인에 대해 테스트할 수 있습니다.

기본 사용법

api.modifyClass는 Ember 리졸버를 통해 접근할 수 있는 모든 클래스의 함수와 프로퍼티를 수정하는 데 사용할 수 있습니다. 여기에는 Discourse의 라우트, 컨트롤러, 서비스 및 컴포넌트가 포함됩니다.

modifyClass는 두 개의 인수를 받습니다:

  • resolverName (문자열) - 유형(예: component/controller 등)을 사용하여 구성한 후 콜론을 붙이고, 클래스의 (대시화된) 파일 이름 이름을 따릅니다. 예를 들어: component:d-button, component:modal/login, controller:user, route:application 등.

  • callback (함수) - 기존 클래스 정의를 받아 확장된 버전을 반환하는 함수.

예를 들어, d-buttonclick() 액션을 수정하려면:

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

class extends ... 구문은 JS 자식 클래스의 구문을 모방합니다. 일반적으로 자식 클래스에서 지원되는 모든 구문/기능을 여기에 적용할 수 있습니다. 여기에는 super, 정적 프로퍼티/함수 등이 포함됩니다.

그러나 몇 가지 제한 사항이 있습니다. modifyClass 시스템은 클래스의 JS prototype에 대한 변경 사항만 감지합니다. 실질적으로 이는 다음과 같은 것을 의미합니다:

  • constructor()를 도입하거나 수정하는 것은 지원되지 않습니다

    api.modifyClass(
      "component:foo",
      (Superclass) =>
        class extends Superclass {
          constructor() {
            // This is not supported. The constructor will be ignored
          }
        }
    );
    
  • 클래스 필드를 도입하거나 수정하는 것은 지원되지 않습니다(다만 @tracked와 같이 데코레이터가 적용된 클래스 필드는 사용할 수 있음)

    api.modifyClass(
      "component:foo",
      (Superclass) =>
        class extends Superclass {
          someField = "foo"; // NOT SUPPORTED - do not copy
          @tracked someOtherField = "foo"; // This is ok
        }
    );
    
  • 원래 구현의 단순한 클래스 필드는 어떤 방식으로든 오버라이드할 수 없습니다(위와 마찬가지로, @tracked 필드는 다른 @tracked 필드로 오버라이드할 수 있음)

    // Core code:
    class Foo extends Component {
      // This core field cannot be overridden
      someField = "original";
    
      // This core tracked field can be overridden by including
      // `@tracked someTrackedField =` in the modifyClass call
      @tracked someTrackedField = "original";
    }
    

이런 것들을 하고 싶다면, 코어에 새로운 API(예: plugin outlets, transformers, 또는 전용 API)를 도입하는 PR을 만드는 것이 사용 사례를 더 잘 충족시킬 수 있습니다.

레거시 구문 업그레이드

과거에는 modifyClass가 다음과 같은 객체 리터럴 구문을 사용하여 호출되었습니다:

// Outdated syntax - do not use
api.modifyClass("component:some-component", {
  someFunction() {
    const original = this._super();
    return original + " some change";
  }
  pluginId: "some-unique-id"
});

이 구문은 더 이상 권장되지 않으며, 알려진 버그(예: getter 또는 @actions 오버라이드)가 있습니다. 이 구문을 사용하는 모든 코드는 위에서 설명한 네이티브 클래스 구문으로 업데이트되어야 합니다. 일반적으로 변환은 다음과 같이 수행할 수 있습니다:

  1. pluginId 제거 - 더 이상 필요하지 않습니다
  2. 위에서 설명한 최신 네이티브 클래스 구문으로 업데이트
  3. 변경 사항 테스트

문제 해결

클래스가 이미 초기화됨

initializer에서 modifyClass를 사용할 때, 콘솔에 다음과 같은 경고가 표시될 수 있습니다:

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

테마/플러그인 개발에서 이 오류가 일반적으로 도입되는 두 가지 방식이 있습니다:

  • lookup()을 추가하여 오류가 발생함

    부트 프로세스에서 너무 일찍 싱글턴을 lookup()하면, 이후의 modifyClass 호출이 실패하게 됩니다. 이 상황에서는 lookup을 나중에 수행하도록 이동해 보십시오. 예를 들어, 다음과 같은 것을:

    // Lookup service in initializer, then use it at runtime (bad!)
    export default apiInitializer((api) => {
      const composerService = api.container.lookup("service:composer");
      api.composerBeforeSave(async () => {
        composerService.doSomething();
      });
    });
    

    이렇게 변경합니다:

    // 'Just in time' lookup of service (good!)
    export default apiInitializer((api) => {
      api.composerBeforeSave(async () => {
        const composerService = api.container.lookup("service:composer");
        composerService.doSomething();
      });
    });
    
  • 새로운 modifyClass를 추가하여 오류가 발생함

    테마/플러그인이 modifyClass 호출을 추가하여 오류가 도입된 경우, 부트 프로세스에서 더 앞쪽으로 이동해야 합니다. 이는 서비스(예: topicTrackingState)의 메서드를 오버라이드하거나, 앱 부트 프로세스에서 초기화되는 모델(예: service:current-user에 대해 초기화되는 model:user)의 경우 흔히 발생합니다.

    부트 프로세스에서 modifyClass 호출을 더 앞쪽으로 이동하는 것은 일반적으로 호출을 pre-initializer로 이동하고, Discourse의 ‘inject-discourse-objects’ initializer보다 먼저 실행되도록 구성하는 것을 의미합니다. 예를 들어:

    // (plugin)/assets/javascripts/discourse/pre-initializers/extend-user-for-my-plugin.js
    // or
    // (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개의 좋아요