Projeção de conteúdo no Front-end


O que é projeção de conteúdo (content projection)?

projeção de conteúdo é uma forma de um componente utilizar dados/estruturas vindas de um outro componente, com isso nós temos muito poder de criação e customização de componentes.

Bibliotecas ou frameworks nos dão essas possibilidades, Como no angular, que temos ng-content para projeção direta de conteúdo*, ngTemplateOutlet* para definirmos o que será projetado no local desse template, ngTemplateOutletContext para passarmos dados de um template para o pai desse template e por fim o ngTemplateOutletInjector que nos permite passar algum injector via template.Já com React temos os Slots, que são muito similares ao ng-content e com ele podemos definir diferentes partes do nosso componente que irão receber conteúdo para ser projetado.

Quais problemas a projeção de conteúdo quer resolver?

Com a projeção de conteúdo conseguimos criar componentes mais customizáveis e agnósticos a estrutura geral, assim caso necessário podemos realizar mudanças no uso desse componente sem a necessidade de modificá-lo internamente.

Como projetar conteúdo com Angular?

Dentro do Angular temos como projetar conteúdo de um jeito simples com o ng-content, utilizando seletores ou não.

A principal vantagem de utilização do ng-content é a simplicidade do seu uso e como podemos adicionar “poderes” às nossas projeções com os seletores, assim podemos customizar as seções dessa projeção.

<app-card>
  <header>My card</header>
  <p>Content</p>
  <footer>Footer</footer>
</app-card>

Além da projeção básica podemos adicionar labels para nossos seletores, assim conseguimos adicionar partes nos locais desejados

<app-card>
  <h2 header>My card</h2>
  <p>Content</p>
  <p footer>Footer</p>
</app-card>

Como projetar conteúdo com React?

Com React podemos passar valores como children, dessa forma conseguimos, por exemplo utilizar uma div com outros três componentes que o nosso componente (Card) que está esperando essa prop e irá renderizar essa porção de UI.

<Card>
  <p>Content</p>
</Card>

Além da prop children, podemos utilizar outras props para compor melhor o nosso componente

<Card header={<header>Header</header>} footer={<footer>Footer</footer>}>
  <p>Content</p>
</Card>

Até aqui já temos algo bem interessante, com type-safety se estivermos usando TypeScript. Mas podemos melhorar e facilitar essa implementação com uma composição de componentes.

<Card>
  <Card.Header>
    <h2>My header</h2>
  </Card.Header>
  <p>Content</p>
  <Card.Footer>
    <p>Footer</p>
  </Card.Footer>
</Card>

Para atingirmos o resultado acima iremos criar mais dois componentes, header e footer, esses componentes nos ajudarão a compor melhor o componente Card

Componente Header:

type CardHeaderProps = {
  children: React.ReactNode;
};

export const CardHeader = ({ children }: CardHeaderProps) => {
  return <header>{children}</header>;
};

Componente Footer:

type CardFooterProps = {
  children: React.ReactNode;
};

export const CardFooter = ({ children }: CardFooterProps) => {
  return <footer>{children}</footer>;
};

Componente Card finalizado:

import { CardFooter } from "./CardFooter";
import { CardHeader } from "./CardHeader";

type CardProps = {
  children?: React.ReactNode;
};

export const Card = ({ children }: CardProps) => {
  return (
    <div
      style={{
        background: "#ccc",
        borderRadius: "10px",
        width: "250px",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        flexDirection: "column",
        padding: "8px",
      }}
    >
      {children}
    </div>
  );
};

Card.Header = CardHeader;
Card.Footer = CardFooter;

Agora conseguimos uma solução bem mais flexível e customizável, além de que temos uma nomenclatura que faz bastante sentido, assim conseguimos ver cada parte do nosso componente sendo utilizada em conjunto.

RFC: Slots

A RFC (Request for Comments) de Slots do React ainda está sendo discutida e visa implementar uma api simplificada para o uso de slots dentro do React.

Indo além, portais!

Quando precisamos projetar conteúdo de um componente filho para outro componente podemos aplicar o conceito de portais, onde conseguimos passar um esse componente e referenciar o mesmo a outra parte do DOM. Com essa técnica conseguimos criar comportamentos que muitas vezes seriam mais complexos de implementar, conseguindo assim criar abstrações e deixar o nosso Front-end mais simples e escalável.

Portais com Angular

Para a criação de portais com Angular é comum utilizarmos diretivas para compor o componente que será passado pelo portal e o portal de fato. Caso opte por uma diretiva já pronta, podemos utilizar o Portal do Angular Material, que já tem uma implementação de portal.

Primeiro vamos criar uma diretiva para lidarmos com o outlet, ou o local que irá renderizar o componente via portal.

Essa diretiva irá controlar mudanças e mapeará o conteúdo do portal, e após a diretiva do portal (PortalDirective) ativar o evento de mudança, essa diretiva irá criar o um EmbeddedView do nosso elemento do portal no outlet

@Directive({
  selector: "[appPortalOutlet]",
})
export class PortalOutletDirective implements OnDestroy, OnInit {
  @Input()
  public appPortalOutlet: string = "";

  public static portalContents = new Map<string, PortalData>();
  public static portalContentChanges$ = new BehaviorSubject<PortalData[]>([]);
  public static portalOutletClear$ = new ReplaySubject<PortalData>(1);

  private destroyed$ = new ReplaySubject<void>(1);
  private views = new Map<string, EmbeddedViewRef<unknown>>();

  constructor(private viewContainerRef: ViewContainerRef) {}

  ngOnInit(): void {
    PortalOutletDirective.portalContentChanges$
      .pipe(
        map((contents) =>
          contents.filter(
            (content) =>
              content.key === this.appPortalOutlet &&
              !this.views.has(content.key)
          )
        ),
        tap((contents) => {
          contents.forEach((content) => {
            const viewRef = this.viewContainerRef.createEmbeddedView(
              content.value
            );
            viewRef.detectChanges();
            this.views.set(content.key, viewRef);
          });
        }),
        takeUntil(this.destroyed$)
      )
      .subscribe();

    PortalOutletDirective.portalOutletClear$
      .pipe(
        filter((portal) => portal && this.views.has(portal.key)),
        takeUntil(this.destroyed$)
      )
      .subscribe((portal) => {
        this.views.delete(portal.key);
      });
  }

  ngOnDestroy(): void {
    this.destroyed$.next();
    this.destroyed$.complete();
  }
}

Agora na nossa diretiva de portal, o nosso setter appPortal vai disparar as ações da nossa diretiva de outlet quando adicionarmos uma chave para o mesmo.

@Directive({
  selector: "[appPortal]",
})
export class PortalDirective implements OnDestroy {
  private portalData: PortalData | null = null;

  @Input()
  set appPortal(key: string) {
    if (key) {
      this.portalData = {
        key,
        value: this.templateRef,
      };
      PortalOutletDirective.portalContents.set(key, this.portalData);
      PortalOutletDirective.portalContentChanges$.next(
        Array.from(PortalOutletDirective.portalContents.values())
      );
    }
  }

  constructor(private templateRef: TemplateRef<unknown>) {}

  ngOnDestroy(): void {
    if (!this.portalData) return;

    PortalOutletDirective.portalContents.delete(this.portalData.key);
    PortalOutletDirective.portalContentChanges$.next(
      Array.from(PortalOutletDirective.portalContents.values())
    );
    PortalOutletDirective.portalOutletClear$.next(this.portalData);
  }
}

Exemplo de uso:

O uso de ambas as diretivas são bem simples, só precisamos passar a chave do pedaço de UI que onde queremos renderizar **appPortalOutlet* e no **appPortal* é onde vamos definir a parte da UI que será renderizada.

<div class="main-content">
  <p>Main content</p>

  <div *appPortalOutlet="'my-portal'"></div>
</div>

<h3 *appPortal="'my-portal'">My sub header</h3>

Portais com React

React já tem uma implementação de portais pronta, o createPortal, essa função nos permite injetar um componente em um elemento do DOM. Para utilizar o portal, precisamos buscar o elemento no dom. No exemplo criamos um custom hok que recebe um id e retorno esse elemento ou null.

export const usePortal = (elementId: string) => {
  const [element, setElement] = useState<HTMLElement | null>(null);

  useEffect(() => {
    setElement(document.getElementById(elementId));
  }, [elementId]);

  return element;
};

Após a criação do hook podemos criar um componente que encapsula a função createPortal.

export const PortalComponent = ({
  elementOutlet,
  reactNode,
  key,
}: {
  elementOutlet: Element | DocumentFragment;
  reactNode: ReactNode;
  key?: string;
}) => {
  return createPortal(
    reactNode,
    elementOutlet,
    key ?? `${Math.random() * 10000}`
  );
};

Para utilizar essas funcionalidades em conjunto, podemos fazer dessa forma:

function App() {
  const element = usePortal("main-content");

  return (
    <section>
      <div id="main-content" className="main-content">
        main content
      </div>

      {element && (
        <PortalComponent
          elementOutlet={element}
          reactNode={<h3>Sub header</h3>}
        />
      )}
    </section>
  );
}

Conclusão

Como pudemos ver temos diversas formas de projetar conteúdo, slots, ng-content, etc. Algumas mais acopladas aos seus frameworks e outras são mais conceituais e podem ser utilizadas independentemente de frameworks, é sempre importante sabermos o jeito mais recomendado de implementação na plataforma que estamos atuando, porém ao entender o conceito principal de projeção/portais conseguiremos aplicar essas mesmas técnicas sem problema.