DEV Community

Cover image for Vue.js e os princípios do SOLID
Lucas Souza
Lucas Souza

Posted on • Updated on

Vue.js e os princípios do SOLID

Como criar um front-end mais coeso com uma manutenção simples e saudável

Olá pessoal! No meu querido diário de hoje vou falar um pouco sobre Vue.js e como podemos usufruir do SOLID para ter uma aplicação concisa. Meu texto surgiu de estudos e muita coisa dele foi baseado em um Artigo fenomenal do Manu Ustenko chamado How to avoid SOLID principles violations in Vue. JS application.

Esse artigo é de 2019 e utilizava Vue2, então como estou estudando sobre Vue3 decidi trazer uma versão minha como forma de aprofundar mais meus conhecimentos em Vue e no SOLID. Além de todos os componentes possuirem testes unitários.

Os princípios que serão abordados aqui podem ser replicados em qualquer framework de front-end e/ou em qualquer linguagem então caso não queria utilizar o Vue procure um médico use um que seja de sua escolha.

O inicio da jornada

Para entendermos melhor todos os princípios que comportam o SOLID vamos criar um TodoApp. o código de start do projeto está nessa branch do meu repositório. Existem branchs do passo a passo e as mesmas estão nomeadas de acordo com os princípios do SOLID, então caso queira achar alguma basta procurar pelo nome.

Mas porque devemos saber SOLID ?

As praticas de utilização do SOLID são mais eficazes em arquiteturas no back-end e isso é um fato, mas, ainda sim podemos extrair muitas coisas benignas disso para criar interfaces mais concisas e com uma lógica simples porém eficaz.

No dia-a-dia de uma empresa esses princípios vão ser utilizados a todo momento. Você vai desenvolver diariamente componentes que receberam informações de outro componente que vem de outro local e assim sucessivamente. Com o final dessa leitura sua visão sobre um código será completamente diferente da que você tinha no começo.

Entendo os conceitos do SOLID, será mais simples entender o Clean Code, além de que criar códigos limpos e legíveis dão uma vida útil maior ao seu produto.

Dependências para rodar a aplicação

  • Node 16.13LTS
  • Yarn

Single Responsibility Principle (Principio da Responsabilidade Única)

Esse principio aborda que uma classe, função ou estrutura não deve ter mais de uma responsabilidade.

Em nosso caso o componente HomeView possui 3 responsabilidades:

  • Mostrar o Header da aplicação
  • Carregar os todos na tela
  • Fazer a conexão a API

Nós não queremos isso! Com o tempo esse componente iria crescer mais e mais e suas responsabilidades junto a isso.

Vamos imaginar um componente no mundo real, algo que fosse responsável por efetuar o pagamento de uma compra, mostrar todos os itens dessa compra etc... Não seria legal ter a configuração de stores, conexões com api e diversas outras regras de negócio no mesmo componente não é ? Além de ter um tempo de carregamento enorme, poderia chegar a 3000 linhas ou mais tornando impossível fazer uma manutenção ou criar algo novo. Com o tempo isso se escalaria e no futuro se tornar impossível criar ou remover algo.

Então resumidamente esse principio visa deixar responsabilidades separadas para contemplar um contexto maior.

Com tudo isso em mente vamos refatorar o nosso código! Primeiro vamos remover essa responsabilidade do Header da aplicação.

HomeHeader.vue

<template>
  <header class="header">
    <nav class="header__nav" />
    <div class="header__container">
      <h1>My Todo List</h1>
    </div>
  </header>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'HomeHeader'
})
</script>

<style src="./HomeHeader.scss" lang="scss" scoped />

Enter fullscreen mode Exit fullscreen mode

HomeHeader.scss

$space-24: 24px;

.header {
  width: 100%;

  &__nav {
    background: teal;
    width: 100%;
    height: 50px;
  }

  &__container {
    padding: $space-24;
  }
}
Enter fullscreen mode Exit fullscreen mode

Aqui criamos o componente HomeHeader que será responsável por mostrar esse novo titulo da Home e se caso no futuro tiver outras funcionalidades que envolva esse contexto como botão de logout, toggle de darkmode, etc poderá ser armazenado aqui.

O próximo componente a ser criado será o TodoList

TodoList.vue

<template>
  <div class="todo-list__container">
    <div
      :key="todo.id"
      v-for="todo in todos"
      class="todo-list__tasks"
    >
      <span :class="{ 'todo-list__tasks-completed': todo.completed }">
        {{ todo.title }}
      </span>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { ITodos } from '@/helpers/interfaces/ITodos'

export default defineComponent({
  name: 'TodoList',
  props: {
    todos: {
      type: Object as () => ITodos[],
      required: true
    }
  }
})
</script>

<style src="./TodoList.scss" lang="scss" scoped />
Enter fullscreen mode Exit fullscreen mode

TodoList.scss

$task-color: #4169e1;
$task-completed-color: #2e8b57;

$space-24: 24px;
$home-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
$hover-box-shadow:  0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
$home-transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);


.todo-list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: stretch;

  &__container {
    padding: $space-24;
  }

  &__tasks {
    width: 24%;
    padding: $space-24;
    margin: 0.5%;
    text-align: left;
    color: $task-color;
    box-shadow: $home-box-shadow;
    transition: $home-transition;

    &:hover {
      box-shadow: $hover-box-shadow;
    }

    &-completed {
      color: $task-completed-color;
      text-decoration: line-through;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Removemos a lógica de mostrar o todo da Home, será apenas necessário passar a prop na Home e os TODOS serão renderizados sem maiores problemas.

Por último vamos remover a lógica de fetch na API da Home, visto que não é necessário ela ter conhecimento disso e nem ser responsável por isso.

Api.ts

export default async (url: string) => {
  const baseUrl = 'https://jsonplaceholder.typicode.com/'

  const response = await fetch(`${baseUrl}${url}`)
  return await response.json()
}
Enter fullscreen mode Exit fullscreen mode

Resultado final

HomeView.vue

<template>
  <div class="home">
    <HomeHeader />
    <main>
      <TodoList :todos="todos" />
    </main>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { ITodos } from '@/helpers/interfaces/ITodos'
import TodoList from '@/components/TodoList/TodoList.vue'
import HomeHeader from '@/components/HomeHeader/HomeHeader.vue'
import Api from '@/api/api'

export default defineComponent({
  name: 'HomeView',
  components: { HomeHeader, TodoList },
  async mounted() {
    this.todos = await this.addTodo()
  },
  data() {
    return {
      todos: [] as ITodos[]
    }
  },
  methods: {
    async addTodo(): Promise<ITodos[]> {
      const api = Api('todos')
      return await api
    }
  }
})
</script>
Enter fullscreen mode Exit fullscreen mode

Ufaa! Essa é a nossa Home pós refatoramento! Dito isso é bem perceptível quanto código removemos da Home e respectivamente a quantidade de responsabilidades removidas.

Anteriormente o componente Home era responsável por:

  • Mostrar o Header.
  • Fazer conexão com a API para buscar os dados.
  • Mostrar todos os todos.

Agora ela somente renderizar esses componentes e mostrar o resultado, não mais estando lotada de lógicas que não lhe fazem sentido. Ficando assim bem separado e com fácil manutenibilidade.

Open Closed Principle (Princípio do "Aberto Fechado")

O OCP(Open Closed Principle) declara que Objetos e entidades devem ser abertas para extensão, mas fechadas para modificação.

Atualmente o nosso componente TodoList recebe uma prop chamada todo que é responsável por passar o nosso objeto e as informações do componente serão renderizadas baseadas nisso.

Dentro do TodoList, existe um v-for que é responsável por essa função de atribuir os elementos para o destino correto. Mas como o OCP prevê, essa é uma responsabilidade que não deve ser de um componente.

Perceba, se um componente é responsável por gerar novos elementos internos ele abdicará de ser extensível e retornaremos ao primeiro principio.

Oque o OCP quer dizer com aberto para estender, mas fechada para modificação ?

Que um componente pode "aumentar" de tamanho mas nunca ser modificado. Então o nosso TodoList poderá sempre gerar novas listas de tarefas mas nunca ser capaz de modificar essas mesmas listas.

E para fazer isso no vue é bastante simples, vamos utilizar os slots e as props. Os slots vão ser responsáveis por abrir um espaço em determinado componente para ser capaz de renderizar algo especifico. Podendo ser um novo componente que faz parte desse contexto.

Um pouco confuso ? Vamos ver no código!

Primeiro vamos remover a responsábilidade de gerar essa todo do componente e por em um novo, que será chamado de TodoCard.

TodoCard.vue

<template>
  <div class="todo-card__tasks">
    <span :class="{ 'todo-card__tasks-completed': todoCompleted }">
      {{ todoTitle }}
    </span>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'TodoCard',
  props: {
    todoCompleted: {
      type: Boolean,
      default: false
    },
    todoTitle: {
      type: String,
      default: ''
    }
  }
})
</script>

<style src="./TodoCard.scss" lang="scss" scoped />
Enter fullscreen mode Exit fullscreen mode

TodoCard.scss

$task-color: #4169e1;
$task-completed-color: #2e8b57;

$space-24: 24px;
$home-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
$hover-box-shadow:  0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
$home-transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);

.todo-card {
  &__tasks {
    width: 24%;
    padding: $space-24;
    margin: 0.5%;
    text-align: left;
    color: $task-color;
    box-shadow: $home-box-shadow;
    transition: $home-transition;

    &:hover {
      box-shadow: $hover-box-shadow;
    }

    &-completed {
      color: $task-completed-color;
      text-decoration: line-through;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Esse componente ficará com a responsabilidade de preencher as listas com conteúdo. Então vamos utilizar props para tirar vantagem da comunicação entre componentes, assim podendo pegar os conteúdos e passar entre componentes.

Após isso vamos adequar o nosso TodoList

TodoList.vue

<template>
  <div class="todo-list">
    <div class="todo-list__container">
      <slot></slot>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'TodoList'
})
</script>

<style src="./TodoList.scss" lang="scss" scoped />

Enter fullscreen mode Exit fullscreen mode

TodoList.scss

$space-24: 24px;

.todo-list {
  padding: $space-24;

  &__container {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: stretch;
  }
}
Enter fullscreen mode Exit fullscreen mode

Agora o nosso TodoList é totalmente expansível de acordo com a necessidade de existências de novos TodoCard.

Resultado final

HomeView.vue

<template>
  <div class="home">
    <HomeHeader />
    <main>
      <TodoList>
        <TodoCard
          v-for="todo in todos"
          :key="todo.id"
          :todoCompleted="todo.completed"
          :todoTitle="todo.title"
        />
      </TodoList>
    </main>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { ITodos } from '@/helpers/interfaces/ITodos'
import TodoList from '@/components/TodoList/TodoList.vue'
import TodoCard from '@/components/TodoCard/TodoCard.vue'
import HomeHeader from '@/components/HomeHeader/HomeHeader.vue'
import Api from '@/api/api'

export default defineComponent({
  name: 'HomeView',
  components: {
    HomeHeader,
    TodoList,
    TodoCard
  },
  async mounted() {
    this.todos = await this.addTodo()
  },
  data() {
    return {
      todos: [] as ITodos[]
    }
  },
  methods: {
    async addTodo(): Promise<ITodos[]> {
      const api = Api('todos')
      return await api
    }
  }
})
</script>
Enter fullscreen mode Exit fullscreen mode

Agora ficou mais claro não ? O TodoList terá de aumentar de acordo com a necessidade de surgir mais TodoCard como foi dito anteriormente, ou seja, não será mais internamente ao TodoList, essa responsabilidade se torna do TodoCard e o TodoList transforma-se em um "wrapper". Que é um componente genérico ou uma classe "abstrata" responsável por renderizar os Todos.

Liskov Substitution Principle (Princípio da Substituição de Liskov)

LSP diz que subclasses devem ser substituidas pela base.

Esse é um principio muito especifico que na maioria das vezes será utilizado somente por chamadas a API. Normalmente o Principio da substituição é difundida em aplicações back-end, mas da para extrair algumas coisas aqui.

É bem perceptível que até aqui todos os princípios tem objetivos em comum que geram um resultado maior, nesse em especifico precisamos deixar explico ao nosso código que tudo que for dependente de outra classe deve ser de fácil substituição pela classe pai. Ou seja, se tivemos inúmeras chamadas a diversos endpoints a classe pai deverá ter o maior controle sob essas outras dependências.

Mas cuidado se você tentar criar muitos laços entre classes tem a chance de quebrar o principio do OCP.

Na nossa aplicação vamos adequar todas as chamadas a API que possuímos.

BaseApi.ts

export class BaseApi {
  protected baseUrl = 'https://jsonplaceholder.typicode.com/'
  async get(url: string) {}
}
Enter fullscreen mode Exit fullscreen mode

AxiosApi

import axios from 'axios'
import { BaseApi } from '@/api/BaseApi'

export class AxiosApi extends BaseApi {
  constructor() {
    super()
  }
  async fetch(url: string) {
    const { data } = await axios.get(`${this.baseUrl}${url}`)
    return data
  }
}
Enter fullscreen mode Exit fullscreen mode

FetchApi

import { BaseApi } from '@/api/BaseApi'

export class FetchApi extends BaseApi {
  constructor() {
    super()
  }
  async get(url: string) {
    const response = await fetch(`${this.baseUrl}${url}`)
    return await response.json()
  }
}
Enter fullscreen mode Exit fullscreen mode

Resultado final

Nosso código agora é controlado pelo BaseApi, onde toda nova classe que precisar fazer algum tipo de procura na API será controlado por ele.

import { BaseApi } from '@/api/BaseApi'
import { FetchApi } from '@/api/FetchApi'
import { AxiosApi } from '@/api/AxiosApi'

export class Api extends BaseApi {
  private provider: any = new AxiosApi()
  async get(url: string): Promise<any> {
    return await this.provider.fetch(url)
  }
}
Enter fullscreen mode Exit fullscreen mode

Interface Segregation Principle (Princípio da segregação de interfaces)

Um componente não deve ser forçado a implementar uma propriedade ou interface que ele não utiliza.

É notável que esse principio aborda a necessidade de que os componentes só devem ter propriedades que somente irão suprir as suas necessidades e nada além disso. Para ser mais explicito em relação a isso vamos criar um novo componente chamado TodoRow

TodoRow.scss

$task-color: #4169e1;
$task-completed-color: #2e8b57;

.todo-row {
  width: 100%;
  text-align: left;
  color: $task-color;

  &__completed {
    color: $task-completed-color;
    text-decoration: line-through;
  }
}
Enter fullscreen mode Exit fullscreen mode

TodoRow.vue

<template>
  <div class="todo-row">
    <span>{{ todo.id }}</span>
    <span :class="{ 'todo-row__completed': todo.completed }">
      {{ todo.title }}
    </span>
  </div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { ITodos } from '@/helpers/interfaces/ITodos'

export default defineComponent({
  name: 'TodoRow',
  props: {
    todo: {
      type: Object as () => ITodos,
      required: true
    }
  }
})
</script>
<style src="./TodoRow.scss" lang="scss" scoped />
Enter fullscreen mode Exit fullscreen mode

Aqui percebemos que o componente agora possui somente uma prop que vai ser responsável por passar todas as sub dependências necessárias para o componente e não mais 2 como é no TodoCard.

Resultado Final

Vindo para a HomeView vamos ver essa diferencia mais nítida.

HomeView.vue

<template>
  <div class="home">
    <HomeHeader />
    <main>
      <TodoList>
        <!-- <TodoCard
          v-for="todo in todos"
          :key="todo.id"
          :todoCompleted="todo.completed"
          :todoTitle="todo.title"
        /> -->
        <TodoRow v-for="todo in todos" :key="todo.id" :todo="todo" />
      </TodoList>
    </main>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Para cada elemento que existe dentro do componente TodoCard é necessário passar a sua prop e também o valor, algo que não é necessário e pode ser resolvido facilmente com apenas uma prop que alimentará o componente.

Dependency Inversion Principle (Princípio da inversão de dependência)

As funcionalidades ou classes devem depender de abstrações não de implementações, ou seja, uma classe pai não deve depender do seu filho e sim de classes abstratas.

Mais uma vez um principio do solid que torna-se mais útil com conexões a API. Esse principio visa a decentralização de dependências de classes de alto nível dos seus filhos. Um filho não pode mudar uma classe pai, mas sim o pai.

No nosso caso vamos somente criar uma interface para o método get que possuímos na aplicação e implementas em todas as chamadas que possuímos.

export interface IApi {
  get(url: string): Promise<any>
}
Enter fullscreen mode Exit fullscreen mode

Essa interface vai ser responsável por ter o nosso método get e nas demais classes que dependem disso vamos implementar esse método e ele não será mais repetivo.

Resultado Final

Api.ts

import { IApi } from '@/helpers/interfaces/IApi'
import { BaseApi } from '@/api/BaseApi'
import { FetchApi } from '@/api/FetchApi'
import { AxiosApi } from '@/api/AxiosApi'

export class Api extends BaseApi implements IApi {
  private provider: any = new AxiosApi()
  async get(url: string): Promise<any> {
    return await this.provider.fetch(url)
  }
}
Enter fullscreen mode Exit fullscreen mode

BaseApi.ts

import { IApi } from '@/helpers/interfaces/IApi'

export class BaseApi implements IApi {
  protected baseUrl = 'https://jsonplaceholder.typicode.com/'
  async get(url: string) {}
}
Enter fullscreen mode Exit fullscreen mode

FetchApi.ts

import { IApi } from '@/helpers/interfaces/IApi'
import { BaseApi } from '@/api/BaseApi'

export class FetchApi extends BaseApi implements IApi {
  constructor() {
    super()
  }
  async get(url: string) {
    const response = await fetch(`${this.baseUrl}${url}`)
    return await response.json()
  }
}
Enter fullscreen mode Exit fullscreen mode

AxiosApi.ts

import axios from 'axios'
import { BaseApi } from '@/api/BaseApi'
import { IApi } from '@/helpers/interfaces/IApi'

export class AxiosApi extends BaseApi implements IApi {
  constructor() {
    super()
  }
  async fetch(url: string) {
    const { data } = await axios.get(`${this.baseUrl}${url}`)
    return data
  }
}
Enter fullscreen mode Exit fullscreen mode

Considerações Finais

Ufaaaa! Quanto conteúdo não ? Com tudo isso na nossa mente vamos organizar todas essas ideias e resumir bem.

Entendemos durante a nossa jornada que os princípios do SOLID nós ajudaram a construir uma aplicação mais limpa, um código legível em que cada parte é responsável por sua funcionalidade e as informações compartilhadas entre eles devem mostrar somente para quem é o destinatário daquele conteúdo.

Entendemos que componentes devem ter uma única responsabilidade e nada mais que isso. Com essa visão quando você estiver criando códigos pensará mais em durabilidade e manutenibilidade daquilo, já que é mais fácil fazer manutenção em algo que tem um objetivo e nada mais que isso.

Vimos também que um componente deve ser aberto para se expandir porém nunca para ser alterado, que devem ser passadas apenas as propriedades que realmente vão ser utilizadas e nada a mais.

Tendo em vista isso tudo como você sairá daqui ? Espero que um dev melhor, por códigos mais limpos e legíveis.

Muito obrigado pelo seu tempo e boa sorte!

Discussion (0)