Как мы общаемся с React-компонентами при помощи декораторов в TypeScript

    При разработке приложений на React довольно неудобно создавать независимые друг от друга компоненты, т.к. стандартным способом для обмена данными между ними является "Lifting State Up". Этот подход постепенно загрязняет ненужными свойствами промежуточные компоненты, делая их неудобными для повторного использования.

    image

    Наиболее популярными средствами решения этой проблемы (и некоторых других) являются такие библиотеки как Redux и Mobx, позволяющие хранить данные в отдельном месте и передавать их компонентам напрямую. В этой статье я хочу продемонстрировать наш подход к решению данного вопроса.

    Отдельная страница в СЭД Docsvision собирается в специальном WYSIWYG-редакторе из множества React-компонентов, помещенных на нужные позиции:

    image

    Партнеры могут писать свои JavaScript-сценарии, которые взаимодействуют с компонентами через их API (читают/записывают свойства, вызывают методы), а могут рендерить эти же компоненты через обычный JSX-синтаксис (например, внутри каких-то модальных окон или собственных компонентах).

    Таким образом, наши компоненты должны предлагать три способа взаимодействия:

    1. Получение параметров с сервера, настроенных в WYSIWYG-редакторе.
    2. Взаимодействие с помощью JavaScript-сценариев.
    3. Взаимодействие через JSX.

    Чтобы поддержать все эти способы взаимодействия, мы разработали систему параметров, которая несколько улучшает стандартный механизм свойств. Выглядит это примерно так:

    // Получаем нужный компонент по названию из нашего хранилища компонентов.
    let textBox = layout.controls.textBox;
    
    // Изменяем значение полученного компонента через механизм параметров.
    textBox.params.value = "Мой текст";
    // Приводим значение в верхний регистр при изменении.
    textBox.params.dataChanged = (sender, data)=> sender.params.value = data.newValue.toUpperCase();
    

    А так выглядит сам класс для работы с параметрами у компонента TextBox:

    class TextBoxParams {
        /** Значение. */
        @rw value?: string = '';
        /** Можно ли редактировать компонент. */
        @r canEdit?: boolean;
        /** Событие, возникающее при изменении значения. */
        @apiEvent dataChanged?: BasicApiEvent<string>;
    }
    

    Как мы видим, кроме обычного перечисления свойств, как в стандартном механизме свойств, здесь имеются ещё и декораторы @r, @rw и @apiEvent. С их помощью мы создаем более гибкое поведение для наших свойств.

    И так как этот же класс используется и в качестве интерфейса для React-свойств, то мы можем одинаково взаимодействовать с компонентом как внешними скриптами, так и через в JSX.


    Наиболее часто используемыми для свойств декораторами оказались:

    Название декоратора Описание
    @r
    Свойство доступно только для чтения, не позволяя изменить его.
    @rw
    Свойство доступно как для чтения, так и для записи.
    @apiEvent
    Указывает, что мы должны рассматривать значение свойства как обработчик для событий компонента с аналогичным именем. Также, при работе с такими свойствами мы реализовываем специфическую для событий логику (к примеру, автоматическую отписку предыдущего обработчика при установке нового значения свойства).
    @handler(paramName)
    В отличии от перечисленных выше, этот декоратор вешается не на свойство, а на любой геттер или сеттер внутри компонента. Это позволяет добавлять свою логику при записи или чтении значения свойства. Например, обрезание пробелов с начала и конца значения:

    class TextBoxParams {
        /** Значение. */
        @rw value?: string = '';
    }
    
    class TextBox extends BaseControl<TextBoxProps, TextBoxState> {
        ...
        @handler('value')
        private get value(): string {
            return this.state.value.trim();
        }
        ...
    }
    


    При этом, сами декораторы обычно не содержат какой-либо бизнес-логики, а лишь сохраняют информацию о том, что за декоратор был применён. Это делается с помощью библиотеки reflect-metadata и удобно тем, что появляется возможность хранить логику в другом месте, гибко объединяя несколько привязанных метаданных. Рассмотрим использование этой библиотеки на упрощённом примере с декоратором @r:

    // Название ключа для хранения метаданных касательно декоратора @r.
    const READONLY_DECORATOR_METADATA_KEY = "CONTOL_PUBLIC_API_READONLY";
    
    // Код декоратора @r, в нём метаданные привязываются к указанному объекту.
    export function r(target: Object, propertyKey: string | symbol) {
        Reflect.defineMetadata(READONLY_DECORATOR_METADATA_KEY, true, target, propertyKey);
    }
    
    // Функция для более удобного просмотра наличия метаданных @r у объекта.
    export function isReadonly(target: Object, propertyKey: string): boolean {
        return Reflect.getMetadata(READONLY_DECORATOR_METADATA_KEY, target, propertyKey);
    }
    

    После применения данного декоратора на каком-нибудь свойстве объекта, к этому свойству автоматически привяжутся метаданные с названием «CONTOL_PUBLIC_API_READONLY» и значением true.

    Используя такие метаданные, мы можем динамически задавать нужное поведение нашим параметрам (модификаторы доступа, работу с событиями из таблицы выше и т.д.). Пример простейшей реализации приведён под спойлером ниже.

    Пример кода с реализацией
    class TextAreaParams {
        @r value: string = '';
    }
    
    /** См. пункт 1 ниже. */
    interface ITextAreaState extends TextAreaParams {
    }
    
    class TextArea extends React.Component<TextAreaParams, ITextAreaState> {
        /** См. пункт 2 ниже. */
        params: TextAreaParams = {} as TextAreaParams;
    
        constructor(props: ITextAreaProps) {
            super(props);
    
            /** См. пункт 3 ниже. */
            this.state = new TextAreaParams() as ITextAreaState;
    
            /** См. пункт 4 ниже. */
            for (let propName in this.state) {
                let descriptor = {
                    get: () => this.getParamValue(propName),
                    set: (value: any) => this.	(propName, value),
                    configurable: true,
                    enumerable: true
                 } as PropertyDescriptor;
    
                Object.defineProperty(this.params, propName, descriptor);
            }
    
            /** См. пункт 5 ниже. */
            for (let propName in this.props) {
                this.setParamValue(propName, this.props[propName], true);
            }
        }
    
        /** См. пункт 6 ниже. */
        componentWillReceiveProps(nextProps: ITextAreaProps) {
            for (let propName in this.props) {
                if (this.props[propName] != nextProps[propName]) {
                    this.setParamValue(propName, this.props[propName]);
                }
            }
        }
    
        /** См. пункт 7 ниже. */
        getParamValue(paramName: string) {
            return this.state[paramName];
        }
    
        /** См. пункт 8 ниже. */
        setParamValue(paramName: string, value: any, initial: boolean) {
            const readOnly = isReadonly(this.state, paramName);
    
            if (!readOnly || initial) {
                this.state[paramName] = val;
                this.forceUpdate();
            } else {
                if (this.props[paramName] != value) {
                    console.warn("Свойство " + paramName + " доступно только для чтения.");
                }
            }
        }
    }
    

    1. Интерфейс для state компонента наследуется от класса Params, обеспечивая единообразность данных внутри них. Кроме этого, этот же класс используется и в качестве интерфейса для свойств.
    2. Создаем пустой объект для будущей работы с params. Свойства в нём будут заполнены позднее.
    3. Создаем state компонента, который является экземпляром нашего класса для params.
    4. Заполняем свойства params. Как видно, сам объект params не хранит никаких данных, а использует методы getParamValue и setParamValue в качестве геттера и сеттера.
    5. Синхронизируем первоначальные значения props с params.
    6. При поступлении новых значений props, также синхронизируем их с params. Исходя из этого и предыдущего пункта видно, что React-свойства передают свои значения через параметры в state компонента, что позволяет использовать декораторы и для них.
    7. Значение для параметров просто достается из свойства с аналогичным именем из state, т.к. он для нас это «единый источник правды».
    8. При установке нового значения параметра происходит проверка на то, применён ли наш декоратор @r к свойству с помощью созданного выше хелпера isReadonly. Если свойство доступно только для чтения, то в консоли браузера показывается предупреждение об этом и изменения значения не происходит, иначе новые данные просто записываются в state.


    Таким образом, мы получили универсальное API для доступа к компоненту как через свойства React при использовании внутри другого компонента, так и при получении ссылки на компонент и дальнейшей работы с ним как с объектом. А с помощью декораторов работа с ними происходит просто и понятно.

    Надеюсь, демонстрация нашего подхода позволит кому-то упростить своё API для работы с компонентами, а для более подробного ознакомления с декораторами в TypeScript рекомендую статью своего коллеги Темная сторона TypeScript — @декораторы на примерах.
    • +11
    • 3.9k
    • 3
    ДоксВижн
    45.02
    Company
    Share post

    Comments 3

      +1
      Не очень понятен смысл вот этого:
      @rw value?: string = '';
      


      Модификатора доступа нет, без @rw оно не будет доступным для чтения и для записи?

      Read only поле в ES6 и typescript вообще можно сделать убрав сеттер и оставив только геттер.

      Да и зачем вам дескрипторы, если есть поддержка get и set?
        0
        Модификатора доступа нет, без @rw оно не будет доступным для чтения и для записи?

        Согласен, не очень понятно получилось. Суть его использования в том, что в коде декоратора @rw имеется такой кусочек кода:

        Object.defineProperty(target, propertyKey.toString(), {
            value: undefined,
            configurable: true,
            enumerable: true,
            writable: true
        });
        


        С его помощью, мы принудительно объявляем свойства (на которых применён декоратор @rw) со значением undefined когда создаём экземпляр класса параметров.
        Это всё нужно из-за того, что TypeScript вырезает свойства без заданных значений и мы теряем автодополнение в каком-нибудь Chrome DevTools.

        image

        Во-вторых, можно использовать такой декоратор «чтение и запись» для явного переопределения свойств с декоратором «только для чтения» в подклассах.

        Read only поле в ES6 и typescript вообще можно сделать убрав сеттер и оставив только геттер. Да и зачем вам дескрипторы, если есть поддержка get и set?

        Можно, но мы решили, что конечным разработчикам будет проще и короче использовать декораторы для всех таких штук, оставив класс с параметрами максимально простым.
        Плюс декораторами можно удобнее решать и другие проблемы, пряча в них шаблонный повторяющийся код (например, при использования декоратора apiEvent, предыдущая установленная функция-обработчик сама отписывается, но разработчику достаточно использовать этот декоратор и не думать о тонкостях реализации механизма событий).
          –1
          Но зачем вы делаете Object.defineProperty в декораторе?! И зачем вызывать toString у propertyKey?!

      Only users with full accounts can post comments. Log in, please.