Quando você trabalha em uma biblioteca de componentes reutilizáveis, a questão da API é especialmente aguda. Por um lado, você precisa tomar uma decisão confiável e precisa, por outro lado, para satisfazer muitos casos especiais. Isso se aplica ao trabalho com dados e aos recursos externos de vários casos de uso. Além disso, tudo deve ser facilmente atualizado e implementado nos projetos.
Esses componentes precisam de flexibilidade sem precedentes. Ao mesmo tempo, a configuração não pode ser muito complicada, porque será usada pelos idosos e pelo mês de junho. Reduzir a duplicação de código é uma das tarefas da biblioteca de componentes. Portanto, a configuração não pode ser transformada em código de cópia.

Componentes agnósticos de dados
Digamos que criamos um botão com um menu suspenso. Qual será a sua API? Obviamente, ele precisa de alguns itens como entrada - uma variedade de itens de menu. Provavelmente, a primeira versão da interface será assim:
interface MenuItem { readonly text: string; readonly onClick(): void; }
Muito rapidamente, disabled: boolean
será adicionado a isso. Em seguida, os designers vêm e desenham um menu com ícones. E os caras do projeto vizinho, eles desenharão ícones por outro lado. E a interface está crescendo, é necessário cobrir casos cada vez mais especiais e, a partir da abundância de bandeiras, o componente começa a se parecer com a Assembléia da ONU.

Os genéricos vêm em socorro. Se você organizar o componente para que ele não se importe com o modelo de dados, todos esses problemas desaparecerão. Em vez de chamar item.onClick
em um clique, o menu simplesmente emitirá o item clicado. O que fazer depois disso é uma tarefa para os usuários da biblioteca. Mesmo se eles chamarem o mesmo item.onClick
. item.onClick
.
No caso de um estado disabled
, por exemplo, o problema é resolvido usando manipuladores especiais. O método disabledItemHandler: (item: T) => boolean
é passado para o componente disabledItemHandler: (item: T) => boolean
, através do qual cada item é executado. O resultado diz se esse elemento está bloqueado.

Se você estiver usando um ComboBox , poderá lembrar-se de uma interface que armazena uma string para exibição e um valor arbitrário real usado no código. Essa ideia é clara. Afinal, quando o usuário digita o texto, a ComboBox deve filtrar as opções de acordo com a linha inserida.
interface ComboBoxItem { readonly text: string; readonly value: any; }
Mas aqui também surgirão as limitações de tal abordagem - assim que aparecer um design no qual a linha não seja suficiente. Além disso, o formulário conterá um invólucro em vez do valor real; a pesquisa nem sempre é realizada exclusivamente pela representação de cadeias (por exemplo, podemos dirigir em um número de telefone, mas o nome da pessoa deve ser exibido). E o número de interfaces aumentará com o advento de outros componentes, mesmo que o modelo de dados abaixo deles seja o mesmo.
Genéricos e manipuladores também ajudarão aqui. Vamos dar a função (item: T) => string
componente stringify
. O valor padrão é item => String(item)
. Assim, você pode até usar classes como opções, definindo o toString()
nelas. Como mencionado acima, é necessário filtrar as opções não apenas pela representação de cadeias. Este também é um bom caso para usar manipuladores. Você pode fornecer a um componente uma função que recebe uma string de pesquisa e um elemento como entrada. Ele retornará boolean
- isso informará se o item é adequado para a solicitação.
Outro exemplo comum de uso de uma interface é um ID exclusivo, que corresponde a cópias de objetos JavaScript. Quando recebemos o valor do formulário imediatamente, e as opções de seleção vieram em uma solicitação separada do servidor - elas terão apenas uma cópia do elemento atual. Essa tarefa é manipulada por um manipulador que recebe dois elementos como entrada e retorna sua igualdade. A comparação padrão é normal ===
.
O componente de exibição da guia, de fato, não precisa saber de que forma a guia foi entregue a ele: pelo menos com texto, mesmo com um objeto com campos adicionais, mesmo com como. O conhecimento do formato não é necessário para a implementação, mas muitos criam o link para um formato específico. A ausência de vínculos significa que os componentes não acarretarão alterações significativas durante o refinamento, não forçarão os usuários a adaptar seus dados a eles e permitirão combinar componentes atômicos entre si, como cubos de lego.
A mesma escolha de itens é adequada para o menu de contexto, bem como para a caixa de combinação, seleção, seleção múltipla e componentes simples são facilmente incluídos em projetos mais complexos. No entanto, você precisa ser capaz de exibir dados arbitrários.

As listas podem conter avatares, cores diferentes, ícones, o número de mensagens não lidas e muito mais.
Para fazer isso, os componentes devem trabalhar com a aparência de maneira semelhante aos genéricos.
Componentes independentes de design
Angular fornece ferramentas poderosas para definir a aparência.
Por exemplo, considere uma ComboBox , pois ela pode parecer muito diversificada. Obviamente, um certo nível de restrições será estabelecido no componente, porque ele deve obedecer ao design geral do aplicativo. Seu tamanho, cores padrão, preenchimento - tudo isso deve funcionar por si só. Não queremos forçar os usuários a pensar em tudo sobre aparência.

Os dados arbitrários são como a água: eles não têm forma, não carregam nada específico em si. Nossa tarefa é oferecer uma oportunidade para definir um "navio" para eles. Nesse sentido, o desenvolvimento de um componente abstraído da aparência é o seguinte:

O componente é uma espécie de prateleira do tamanho necessário e um modelo personalizado é usado para exibir o conteúdo, que é "colocado" nele. Um método padrão, como gerar uma representação de string, é estabelecido inicialmente no componente, e o usuário pode transferir opções mais complexas do exterior. Vamos dar uma olhada nas possibilidades que a Angular tem para isso.
A maneira mais simples de alterar a aparência é a interpolação de linha. Mas uma linha invariável não é adequada para exibir itens de menu, porque não sabe nada sobre cada item - e todos terão a mesma aparência. Uma sequência estática é privada de contexto . Mas é bastante adequado para definir o texto "Nada encontrado" se a lista de opções estiver vazia.
<div>{{content}}</div>
Já falamos sobre a representação em cadeia de dados arbitrários. O resultado também é uma sequência, mas é determinado pelo valor de entrada. Nessa situação, o contexto será um item da lista. Essa é uma opção mais flexível, embora não permita estilizar o resultado - a sequência não é interpolada em HTML - e ainda mais, não permitirá o uso de diretivas ou componentes.
<div>{{content(context)}}</div>
Angular fornece ng-template
e a *ngTemplateOutlet
estrutural *ngTemplateOutlet
para *ngTemplateOutlet
. Com a ajuda deles, podemos definir um pedaço de HTML que espera que alguns dados sejam inseridos e passá-los para o componente. Lá ele será instanciado com um contexto específico. Passaremos nosso elemento para ele sem nos preocuparmos com o modelo. A elaboração do modelo certo para seus objetos é uma tarefa do consumidor-desenvolvedor do nosso componente.
<ng-container *ngTemplateOutlet="content; context: context"></ng-container>
Um modelo é uma ferramenta muito poderosa, mas precisa ser definida em algum componente existente. Isso complica muito sua reutilização. Às vezes, a mesma aparência é necessária em diferentes partes do aplicativo e até em aplicativos diferentes. Na minha prática, isso, por exemplo, é a aparência da seleção de conta com a exibição do nome, moeda e saldo.

A maneira mais complexa de personalizar a aparência que resolve esse problema são os componentes dinâmicos. No Angular, a diretiva *ngComponentOutlet
existe há muito tempo para criá-los declarativamente. Não permite a transferência de contexto, mas esse problema é resolvido pela implementação de dependências. Podemos criar um token para o contexto e adicioná-lo ao Injector
com o qual o componente é criado.
<ng-container *ngComponentOutlet="content; injector: injector"></ng-container>
Vale ressaltar que o contexto pode ser não apenas o elemento que queremos exibir, mas também as circunstâncias em que está localizado:
<ng-template let-item let-focused="focused"> </ng-template>
Por exemplo, no caso de retirada de uma conta, o estado do foco do item é refletido na aparência - o plano de fundo do ícone muda de cinza para branco. Em termos gerais, faz sentido transferir para o contexto as condições que potencialmente afetam a exibição do modelo. Este ponto é talvez a única interface de limitação dessa abordagem.

Tomada Universal
As ferramentas descritas acima estão disponíveis no Angular a partir da quinta versão. Mas queremos mudar facilmente de uma opção para outra. Para fazer isso, montaremos um componente que aceita conteúdo e contexto como entrada e implementa a maneira apropriada de inserir esse conteúdo automaticamente. Em geral, é suficiente aprendermos a distinguir entre os tipos string
, number
, (context: T) => string | number
(context: T) => string | number
, TemplateRef<T>
e Type<any>
(mas há algumas nuances aqui, que discutiremos abaixo).
O modelo do componente ficará assim:
<ng-container [ngSwitch]="type"> <ng-container *ngSwitchCase="'primitive'">{{content}}</ng-container> <ng-container *ngSwitchCase="'function'">{{content(context)}}</ng-container> <ng-container *ngSwitchCase="'template'"> <ng-container *ngTemplateOutlet="content; context: context"></ng-container> </ng-container> <ng-container *ngSwitchCase="'component'"> <ng-container *ngComponentOutlet="content; injector: injector"></ng-container> </ng-container> </ng-container>
O código obterá um getter de tipo para selecionar o método apropriado. Deve-se notar que, em geral, não podemos distinguir um componente de uma função. Ao usar módulos preguiçosos, precisamos de um Injector
que saiba sobre a existência do componente. Para fazer isso, criaremos uma classe de wrapper. Isso também tornará possível determiná-lo por instanceof
:
export class ComponentContent<T> { constructor( readonly component: Type<T>, private readonly injector: Injector | null = null, ) {} }
Adicione um método para criar um injetor com o contexto passado:
createInjectorWithContext(injector: Injector, context: C): Injector { return Injector.create({ parent: this.injector || injector, providers: [ { provide: CONTEXT, useValue: context, }, ], }); }
Quanto aos modelos, na maioria dos casos, você pode trabalhar com eles como estão. Mas devemos ter em mente que o modelo está sujeito à verificação de alterações no local de sua definição. Se você transferi-lo para a Visualização , que é paralela ou mais alta na árvore a partir do local da definição, as alterações que possam ser provocadas nela não serão captadas na Visualização original.
Para corrigir essa situação, usaremos não apenas um modelo, mas uma diretiva que também terá ChangeDetectorRef
seu local de definição. Dessa forma, podemos começar a verificar alterações quando necessário.
Padrões polimórficos
Na prática, pode ser útil controlar o comportamento do modelo, dependendo do tipo de conteúdo que entrou nele.
Por exemplo, queremos dar a oportunidade de transferir um modelo para um componente para algo especial. Ao mesmo tempo, na maioria dos casos, você só precisa de um ícone. Em tal situação, você pode configurar o comportamento padrão e usá-lo quando uma primitiva ou função entrar na entrada. Às vezes, até o tipo de primitivo é importante: por exemplo, se você possui um componente de emblema para exibir o número de mensagens não lidas em uma guia, mas deseja destacar as páginas que requerem atenção com um ícone especial.

Para fazer isso, você precisa adicionar mais uma coisa - passar um modelo para exibir primitivas. Adicione @ContentChild
ao componente, que retira TemplateRef
do conteúdo. Se um for encontrado e uma função, string ou número for passado para o conteúdo, podemos instancia-lo com o primitivo como um contexto:
<ng-container *ngSwitchCase="'interpolation'"> <ng-container *ngIf="!template; else child">{{primitive}}</ng-container> <ng-template #child> <ng-container *ngTemplateOutlet="template; context: { $implicit: primitive }" ></ng-container> </ng-template> </ng-container>
Agora podemos estilizar a interpolação ou até passar o resultado para algum componente para exibição:
<content-outlet [content]="content" [context]="context"> <ng-template let-primitive> <div class="primitive">{{primitive}}</div> </ng-template> </content-outlet>
É hora de colocar nosso código em prática.
Use
Por exemplo, descrevemos dois componentes: guias e ComboBox . O modelo da guia consiste simplesmente em uma saída de conteúdo para cada guia, onde o objeto passado pelo usuário será o contexto:
<content-outlet *ngFor="let tab of tabs" [class.disabled]="disabledItemHandler(tab)" [content]="content" [context]="getContext(tab)" (click)="onClick(tab)" ></content-outlet>
Você precisa definir estilos padrão: por exemplo, tamanho da fonte, sublinhado na guia atual, cor. Mas deixaremos a aparência concreta para o conteúdo. O código do componente será algo como isto:
export class TabsComponent<T> { @Input() tabs: ReadonlyArray<T> = []; @Input() content: Content = ({$implicit}) => String($implicit); @Input() disabledItemHandler: (tab: T) => boolean = () => false; @Input() activeTab: T | null = null; @Output() activeTabChange = new EventEmitter<T>(); getContext($implicit: T): IContextWithActive<T> { return { $implicit, active: $implicit === this.activeTab, }; } onClick(tab: T) { this.activeTab = tab; this.activeTabChange.emit(tab); } }
Temos um componente que pode trabalhar com uma matriz arbitrária, exibindo-a como guias. Você pode simplesmente passar as strings para lá e obter a aparência básica:

E você pode transferir objetos e um modelo para exibi-los e personalizar a aparência para atender às suas necessidades, adicionar HTML, ícones, indicadores:

No caso do ComboBox, primeiro criaremos dois componentes básicos dos quais ele consiste: um campo de entrada com um ícone e um menu. O último não faz sentido pintar em detalhes - é muito semelhante às guias, apenas na vertical e possui outros estilos básicos. E o campo de entrada pode ser implementado da seguinte maneira:
<input #input [(ngModel)]="value"/> <content-outlet [content]="content" (mousedown)="onMouseDown($event, input)" > <ng-template let-icon> <div [innerHTML]="icon"></div> </ng-template> </content-outlet>
Se você colocar a entrada absolutamente posicionada, ela bloqueará a tomada e todos os cliques estarão nela. Isso é conveniente para um campo de entrada simples com um ícone decorativo, como um ícone de lupa. No exemplo acima, a abordagem de modelo polimórfico é aplicada - a cadeia transmitida será usada innerHTML
para inserir o ícone SVG. Se, por exemplo, precisarmos mostrar o avatar do usuário inserido, podemos transferir o modelo para lá.
O ComboBox também precisa de um ícone, mas precisa ser interativo. Para impedir que o foco seja interrompido, adicione o manipulador onMouseDown
à tomada:
onMouseDown(event: MouseEvent, input: HTMLInputElement) { event.preventDefault(); input.focus(); }
Passar o modelo como conteúdo nos permitirá elevá-lo mais alto através do CSS simplesmente criando a posição: ícone relativo . Em seguida, você pode assinar cliques nele na própria ComboBox :
<app-input [content]="icon"></app-input> <ng-template #icon> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="icon" [class.icon_opened]="opened" (click)="onClick()" > <polyline points="7,10 12,15 17,10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" /> </svg> </ng-template>
Graças a essa organização, obtemos o comportamento desejado:

O código do componente, como no caso de guias, dispensa o conhecimento do modelo de dados. Parece algo como isto:
export class ComboBoxComponent<T> { @Input() items: ReadonlyArray<T> = []; @Input() content: Content = ({$implicit}) => String($implicit); @Input() stringify = (item: T) => String(item); @Input() value: T | null = null; @Output() valueChange = new EventEmitter<T | null>(); stringValue = '';
Esse código simples permite que você use objetos na ComboBox e personalize sua exibição com muita flexibilidade. Após algumas melhorias que não estão relacionadas ao conceito descrito, ele está pronto para uso. A aparência pode ser personalizada para todos os gostos:

Conclusão
A criação de componentes agnósticos elimina a necessidade de levar em consideração cada caso específico. Ao mesmo tempo, os usuários obtêm uma ferramenta simples para configurar o componente para uma situação específica. Essas soluções são fáceis de reutilizar. A independência do modelo de dados torna o código universal, confiável e extensível. Ao mesmo tempo, escrevemos poucas linhas e usamos principalmente as ferramentas angulares incorporadas.
Usando a abordagem descrita, você notará rapidamente como é conveniente pensar em termos de conteúdo, em vez de linhas ou padrões específicos. Exibindo mensagens de erro de validação, dicas de ferramentas, janelas modais - essa abordagem é boa não apenas para personalizar a aparência, mas também para transferir o conteúdo como um todo. Esboçar layouts e testar a lógica é fácil! Por exemplo, para mostrar o pop-up, o usuário não precisa criar um componente ou mesmo um modelo, basta passar a string de stub e retornar a ela mais tarde.
Há muito tempo, no Tinkoff.ru, aplicamos com êxito a abordagem descrita e a transferimos para uma pequena biblioteca de código-fonte aberto (1 KB gzip) chamada ng-polymorpheus .
Código fonte
pacote npm
Demonstração interativa e sandbox
Você também tem algo que deseja colocar em código aberto, mas está assustado com as tarefas associadas? Experimente o Iniciador de biblioteca de código aberto angular , que criamos para nossos projetos. Ele já possui o CI configurado, verifica as confirmações, os linters, a geração do CHANGELOG, a cobertura dos testes e tudo mais.