
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.