Я ежедневно работаю как с Angular, так и с React, а также большой поклонник Vue и Svelte и слежу за их развитием. В этой статье я покажу, как с помощью Angular получить компоненты без рендеринга, как в Vue, и рендер-пропсы, как в React.

Как следует из самого названия, компоненты без рендеринга ничего не рендерят; их единственное назначение — предоставлять функциональность для повторного использования. Давайте возьмем в качестве примера переключатель toggle, написанный на Vue, и преобразуем его на Angular. Вот Vue-версия:

<toggle>
  <div slot-scope="{ on, setOn, setOff }">
    <button @click="click(setOn)">Blue pill</button>
    <button @click="click(setOff)">Red pill</button>
    <div>
      <span v-if="on">It's all a dream, go back to sleep.</span>
      <span v-else>
        I don't know how far the rabbit hole goes, 
        I'm not a rabbit, neither do I measure holes.
      </span>
    </div>
  </div>
</toggle>

Безрендерный компонент toggle отвечает за API для переключателя представления. Ему все равно, как это представление структурировано или стилизовано. Той же функциональности можно достичь в Angular с помощью структурных директив.

Структурные директивы

Структурная директива изменяет макет DOM, добавляя и удаляя элементы DOM (например, представление view). Дополнением к этому служит такая мощная функция, как предоставление объекта context, который доступен любому адресату структурной директивы.

Создадим структурную директиву toggle, которая предоставляет доступ к API через свойство context:

type Toggle = {
  on: boolean;
  setOn: Function;
  setOff: Function;
  toggle: Function;
}

@Directive({ selector: '[toggle]' })
export class ToggleDirective implements OnInit {
  on = true;

  @Input('toggleOn') initialState = true;

  constructor(private tpl: TemplateRef<{ $implicit: Toggle }>, 
              private vcr: ViewContainerRef) {
  }

  ngOnInit() {
    this.on = this.initialState;

    this.vcr.createEmbeddedView(this.tpl, {
      $implicit: {
        on: this.on,
        setOn: this.setOn,
        setOff: this.setOff,
        toggle: this.toggle,
      }
    });
  }

  setOn() { this.on = true }
  setOff() { this.on = false }
  toggle() { this.on = !this.on }
}

Мы создаем view и передаем публичный API, который хотим сделать доступным через context, роль которого исполняет второй параметр в createEmbeddedView. Самое интересное здесь то, что TemplateRef принимает универсальный тип, который служит типом для context. Современные IDE, такие как Webstorm, автоматически выводят его в шаблоне. Давайте им воспользуемся:

<div *toggle="let controller; on: false">
 <button (click)="controller.setOn()">Blue pill</button>
 <button (click)="controller.setOff()">Red pill</button>

 <div>
   <span *ngIf="controller.on">...</span>
   <span *ngIf="!controller.on">...</span>
 </div>
</div>

Спасибо, Webstorm!

ExportAs и его применение

Второй случай разберем на официальном примере рендер-пропсов React. Рендер-пропсы предоставляют возможность одним компонентам совместно использовать состояние или поведение, инкапсулированное в другом компоненте, если им требуется то же самое состояние. Например, следующий компонент React отслеживает положение мыши в веб-приложении:

class Mouse extends React.Component {
  state = { x: 0, y: 0 };

  handleMouseMove = (event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
        {this.props.children(this.state)}
      </div>
    );
  }
}

Мы используем дочерний (children) рендер-проп в качестве функции, которая предоставляет состояние компонента любому потребителю.

Поведение, которым мы хотим поделиться, благодаря этой технике становится максимально портируемым. Чтобы реализовать это поведение, отрисуйте <Mouse> с помощью рендер-пропа, который говорит ей, что нужно визуализировать с текущими координатами курсора (x, y):

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse>
          {mouse => (
            <p>The mouse position is {mouse.x}, {mouse.y}</p>
          )}
        </Mouse>
      </div>
    );
  }
}

Создадим такую же функциональность в Angular:

@Component({
  selector: 'mouse',
  exportAs: 'mouse',
  template: `
    <div (mousemove)="handleMouseMove($event)">
      <ng-content></ng-content>
    </div>
  `
})
export class MouseComponent {
  private _state = { x: 0, y: 0 };
  get state() {
    return this._state;
  };

  handleMouseMove(event) {
    this._state = {
      x: event.clientX,
      y: event.clientY
    };
  }

}

Компонент MouseComponent не связан с его содержимым. Он предоставляет свой API с помощью свойства exportAs, которое сообщает Angular, что мы можем использовать этот API компонента в шаблоне:

@Component({
  selector: 'mouse-tracker',
  template: `
    <mouse #mouse="mouse">
      <p>The mouse position is {{ mouse.state.x }}, {{ mouse.state.y }}</p>
    </mouse>
  `
})
export class MouseTrackerComponent {}

И всё, ничего больше не нужно. Метод exportAs также можно применить вместо структурной директивы для компонента toggle, который мы написали ранее.

Итоги

Не думаю, что есть правильные или неправильные варианты использования, когда речь заходит о структурной директиве или функции exportAs. Преимущества структурных директив заключаются в том, что мы можем явно определить API, который хотим предоставлять в представлении, и проконтролировать, следует ли визуализировать представление или нет. Когда нам все еще нужна какая-то часть представления, как в примере с MouseComponent, можно воспользоваться компонентом и функцией exportAs.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Netanel Basal: Going Renderless in Angular: All of the Functionality, None of the Render

Предыдущая статьяТри примера, когда не стоит использовать стрелочные функции JavaScript
Следующая статьяИнновационный алгоритм глубокого обучения в Google Translate