• #javascript
  • #vue

Como scoped slots podem salvar o seu dia

Publicado em set, 29 2021


Fala turma, hoje gostaria de trazer mais um assunto sobre Vue.js, e dessa vez, vamos falar de slots, um assunto bem interessante, e que esbarramos aqui na empresa um tempo atrás na hora de desenvolvermos uma funcionalidade. Então, sem mais delongas vamos ao texto.

Primeiramente, o que são slots?

Uma das estruturas mais comuns que existem no HTML é o aninhamento de elementos. Vamos pegar como exemplo o seguinte código:

<header>
  <nav>
    <ul>
      <li> Item 1 </li>
      <li> Item 2 </li>
      <li> Item 3 </li>
    </ul>
  </nav>
</header>

Neste simples código nós temos a seguinte estrutura de elementos aninhados: header > nav > ul > li. Dizemos que estes elementos são nested, ou seja, podem conter outros elementos ou conteúdo (como a li que contém um texto). Porém, há outros elementos que não são nested, ou seja, não podem conter elementos ou conteúdo em seu interior. Eles são chamados de empty elements. Alguns exemplos dessa última categoria são as tags img e hr.

Mas como eu consigo trazer esse conceito para componentes Vue.js? Slots for the win!

Um slot serve para definir se um componente pode conter elementos (nested), e também nos ajuda a definir aonde um conteúdo interno será exibido dentro desse componente. Vale destacar que sem um slot definido, um componente não poderá ter elementos dentro dele.

Pegando um exemplo da própria documentação (Vue 3). Nós temos o seguinte exemplo de uso de um componente chamado TodoButton:

<todo-button>
  Add todo
</todo-button>

E, o template desse componente é assim:

<button class="btn-primary">
  <slot></slot>
</button>

Aonde está escrito <slot></slot> será trocado pelo texto "Add todo" ao renderizarmos o componente na tela.

E slots nomeados?

Uma vez entendido o que são slots, vamos dar um passo a frente e trazer o conceito de named slots. Um slot nomeado é útil quando queremos definir mais de um slot no nosso componente. Pegando mais uma vez o exemplo da documentação (Vue 3):

Imagine que nós queremos ter a seguinte estrutura HTML final, mas usando apenas um componente:

<div class="container">
  <header>
    <!-- Nós queremos um cabeçalho aqui -->
  </header>
  <main>
    <!-- O conteúdo principal ficará aqui -->
  </main>
  <footer>
    <!-- Nós queremos um conteúdo em footer aqui -->
  </footer>
</div>

Named slots tornam essa tarefa simples. Basicamente "nomeie" seus slots no seu componente e referencie esses named slots ao usar o componente envolvendo eles com a tag template. O código do nosso componente poderia ficar assim:

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

E quando formos usar nosso componente, faríamos assim:

<base-layout>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>

  <template v-slot:default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>

  <template v-slot:footer>
    <p>Here's some contact info</p>
  </template>
</base-layout>

Neste exemplo, envolvemos o conteúdo do slot default (que não possui nome), em uma tag template também, mas isso é opcional.

Uma vez compreendido o que são slots e named slots, você pode estar se perguntando agora: como fica o escopo de dados dos componentes quando usamos slots? Eu consigo passar dados de um componente via slot? Vamos responder a essas perguntas introduzindo um novo conceito: scoped slot.

Conhecendo scoped slots

Para entendermos bem um scoped slot e seu poder, antes, precisamos entender como funciona o escopo de dados quando usamos slots. Vamos pegar como exemplo, o seguinte componente (inspirado na doc do Vue 2), que vamos chamar de CurrentUser:

<template>
  <span>
    <slot>{{ user.name }}</slot>
  </span>
</template>

<script>
export default {
  name: 'CurrentUser',

  data: () => ({
    user: {
      name: 'John Doe'
    }
  })
}
</script>

No componente pai, que usa o component CurrentUser, nós vamos tentar acessar a propriedade user.

<template>
  <div>
    {{ message }}

    <current-user>
      {{ user.name }}
    </current-user>
  </div>
</template>

<script>
import CurrentUser from 'path/to/CurrentUser'

export default {
  name: 'Base',

  components: {
    CurrentUser
  },

  data: () => ({
    message: 'Just a a message'
  })
}
</script>

Bem, esse código irá produzir um erro. Isso porque no componente pai, nós não temos acesso as propriedades, métodos e outras coisas mais do componente filho. Isso está "encapsulado" dentro dele. O componente pai só tem acesso à propriedade message, pois apenas ela está definida em seu escopo.

Pois bem, como tornar disponível para o escopo do componente pai a propriedade user no componente CurrentUser? Vamos passar para o slot default a propriedade user. Veja como fica o código:

<span>
  <slot v-bind:user="user">
    {{ user.name }}
  </slot>
</span>

Perceba que eu usei a conhecida sintaxe v-bind para "vincular" ao slot o dado que eu quero passar. Como eu uso essa sintaxe, eu também posso usar o seu shorthand, com :.

Mas e como eu consigo "pegar" esse dado no componente pai? Mais um exemplo de código:

<template v-slot:default="slotProps">
  {{ slotProps.user.name }}
</template>

Agora os dados passados para o slot estarão disponíveis no componente pai através da propriedade slotProps.

O mais sensacional dessa funcionalidade é que eu não preciso passar apenas as propriedades do componente, mas também seus métodos. E é aqui que eu gostaria de chegar, pois agora vou trazer um exemplo mais complexo, um componente de Menu, em que iremos discutir em como scoped slots nos ajuda na construção e uso dele.

Criando um componente de Menu

Este componente é livremente inspirado na implementação do vue-chakra.

Vamos a um exemplo prático. O código completo desse nosso exemplo você pode ver diretamente no Codesandbox. Aqui, vamos pegar algumas pequenas partes do código que nos interessam para o nosso exemplo.

Nós criamos os seguintes componentes:

  • CMenu: componente responsável por gerenciar o estado do menu (aberto e fechado);

  • CMenuButton: componente que irá permitir a interação do usuário para a manipulação do estado;

  • CMenuList: componente responsável por renderizar a lista;

  • CMenuItem: componente responsável por renderizar um item da lista.

Ao final, queremos poder usar os componentes acima da seguinte maneira:

<CMenu>
  <CMenuButton> Menu button </CMenuButton>

  <CMenuList>
    <CMenuItem> Item 1 </CMenuItem>
    <CMenuItem> Item 2 </CMenuItem>
    <CMenuItem> Item 3 </CMenuItem>
  </CMenuList>
</CMenu>

A ideia é simples: por padrão, a lista não é exibida, a menos que o usuário clique no botão que irá abrir a lista. Uma vez aberta, a lista pode ser fechada novamente clicando no botão.

Para termos essa funcionalidade transparente para quem usa o componente, nós iremos usar o conceito de injeção de dependência no Vue.js usando as APIs de provide e inject. O componente CMenu irá prover para os componentes filhos um contexto, que é apenas um objeto que eu passar para os componentes filhos sem usar props. Neste contexto, nós iremos colocar o estado isOpen, bem como os métodos que o manipulam. Assim, nosso componente CMenu ficaria assim:

<template>
  <div class="c-menu">
    <slot />
  </div>
</template>

<script>
export default {
  name: "CMenu",

  data: () => ({
    isOpen: false,
  }),

  provide() {
    return {
      menuContext: () => this.computedMenuContext,
    };
  },

  computed: {
    computedMenuContext() {
      return {
        isOpen: this.isOpen,
        toggleState: this.toggleState,
        open: this.open,
        close: this.close,
      };
    },
  },

  methods: {
    open() {
      this.isOpen = true;
    },

    close() {
      this.isOpen = false;
    },

    toggleState() {
      if (this.isOpen) {
        return this.close();
      }

      return this.open();
    },
  },
};
</script>

Eu criei um mixin para facilitar a gerência das informações nos componentes filhos e evitar duplicidade de código:

export default {
  inject: ["menuContext"],

  computed: {
    context() {
      return this.menuContext();
    },

    isOpen() {
      return this.context.isOpen;
    },

    open() {
      return this.context.open;
    },

    close() {
      return this.context.close;
    },

    toggleState() {
      return this.context.toggleState;
    }
  }
};

E em cada componente filho eu uso esse mixin para que eu tenha as computeds com os meus estados e métodos disponíveis para os componentes. Compartilho o código do CMenuList:

<template>
  <ul class="c-menu-list" :class="{ 'c-menu-list--open': isOpen }">
    <slot />
  </ul>
</template>

<script>
import menuMixin from "../utils/menu.mixin";

export default {
  name: "CMenuList",

  mixins: [menuMixin],
};
</script>

<style>
.c-menu-list {
  display: none;
}

.c-menu-list--open {
  display: block;
}
</style>

E do CMenuButton:

Html<template>
  <button class="c-menu-button" @click="toggleState">
    <slot />
  </button>
</template>

<script>
import menuMixin from "../utils/menu.mixin";

export default {
  name: "CMenuButton",

  mixins: [menuMixin],
};
</script>

Assim, chegamos ao seguinte resultado:

Mas como scoped slots podem ser úteis aqui?

Pois bem, imagine que o componente de CMenuButton não provê a funcionalidade que você precisa, e assim você necessita interagir com o CMenu modificando seu estado interno. Como você faria isso?

Uma possível solução que particularmente não acho interessante é usar refs. Se referenciarmos o nosso componente de CMenu teremos acesso aos seus métodos e estado interno e assim eu posso alterá-los conforme a necessidade. Essa abordagem não é boa porque gera um forte acoplamento entre o seu componente e o CMenu. Fora que essa abordagem não é explícita, não é transparente. Exemplo:

<!-- botão para interagir com o componente CMenu -->
<button @click="toggleRefState">
  Toggle usando ref
</button>

<CMenu ref="cMenu">
  <!-- conteúdo -->
</CMenu>

<script>
export default {
  methods: {
    toggleRefState() {
      this.$refs.cMenu.toggleState();
    },
  },
};
</script>

Uma outra solução poderia ser usar props. Eu poderia criar uma propriedade chamada forceIsOpen, e dentro do meu componente CMenu reagir a mudanças nessa propriedade e alterarmos isOpen internamente. Essa abordagem é mais explícita e transparente que a anterior. Um exemplo de implementação para o componente CMenu seria:

<!-- o código do template anterior continua -->
<script>
export default {
  name: "CMenu",

  props: {
    forceIsOpen: Boolean,
  },

  data: () => ({
    isOpen: false,
  }),

  watch: {
    forceIsOpen(value) {
      if (value) {
        this.isOpen = true;
      }
    },
  },
}
</script>

Por fim, existe a abordagem usando scoped slots. Confio que ela seja mais interessante porque me traz mais flexibilidade, mantendo a transparência da abordagem anterior. Para usarmos ela, basicamente só precisamos fazer um bind do que queremos expor para o nosso slot default no componente CMenu:

<div class="c-menu">
  <slot v-bind="computedMenuContext" />
</div>

E no componente pai, que usa o componente CMenu, nós capturamos os dados expostos no slot usando a sintaxe v-slot:

<CMenu v-slot="slotProps">
  <button @click="slotProps.toggleState">
    {{ slotProps.isOpen ? "Outro botão aberto" : "Outro botão fechado" }}
  </button>

  <CMenuList>
    <CMenuItem> Item 1 </CMenuItem>
    <CMenuItem> Item 2 </CMenuItem>
    <CMenuItem> Item 3 </CMenuItem>
  </CMenuList>
</CMenu>

O que está acontecendo aqui? Como o nosso componente CMenu possui apenas um slot, que é o default, e esse slot possui dados vinculados a ele, nós podemos na declaração do componente no template "capturar" esses dados usando o v-slot. Aqui, o v-slot irá vincular (bind) o que está em computedMenuContext para slotProps. Assim, eu tenho acesso ao objeto computedMenuContext, e consequentemente, ao método toggleState e ao estado isOpen. O resultado final é esse:

Lembrando que você pode conferir o exemplo completo no Codesandbox.

Conclusão

Como você pode ter visto, slots são uma funcionalidade muito poderosa do Vue.js e que possibilitam uma grande flexibilidade na hora de pensar nas soluções de comunicação entre os nossos componentes. Se curtiu este texto, não deixe de compartilhá-lo e até a próxima!


Imagem de fundo criada por Jackson So, download pelo Unsplash.