September 15, 2019

Как избежать нарушения принципов SOLID во Vue.js приложении

Данная статья является переводом статьи How to avoid SOLID principles violations in Vue. JS application.

В этой статье я бы хотел обсудить, как мы можем избежать нарушения принципов SOLID в нашем приложении, написанном на Vue.js

Что такое SOLID? SOLID – это аббревиатура, созданная Майклом Фезерсом и продвигаемая американским инженером-программистом Робертом Сесил Мартином (дядя Боб) в его книге «Принципы проектирования и шаблоны проектирования». Эти принципы являются очень важной частью парадигмы объектно-ориентированного программирования, предназначенной для того, чтобы сделать нашу программу более гибкой, читаемой и поддерживаемой для последующей разработки. SOLID включает в себя следующие понятия:

  • Принцип единой ответственности (Single responsibility principle)
  • Принцип открытости / закрытости (Open-close principle)
  • Принцип подстановки Барбары Лисков (Liskov substitution principle)
  • Принцип отделения интерфейсов (Interface segregation principle)
  • Принцип инверсии зависимостей (Dependency inversion principle)

Давайте посмотрим на все эти принципы в реальном приложении на Vue.js, и на то, как мы можем избежать этих нарушений. Для это мы создадим простое TODO-приложение.

Предварительная подготовка

Давайте создадим новое приложение на Vue.js, используя vue cli

vue create todo-app В нашем приложении я собираюсь использовать vue 2.6.10 + typescript 3.4.3 Если вы еще не знакомы с typescript, то вы можете найти документацию здесь.
После установки нам нужно немного почистить и удалить все демонстрационные компоненты.

После чистки, наша структура каталогов src будет выглядеть следующим образом:

src
-- views
---- Home.vue
-- App.vue
-- main.ts
-- shims-tsx.d.ts
-- shims-vue.d.ts
-- types.ts
// App.vue

<template>
  <div id="app">
    <router-view />
  </div>
</template>
// views/Home.vue

<template>
  <div>
    Content
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'

@Component
export default class Home extends Vue {}
</script>

Теперь мы готовы идти дальше...

Принцип единой ответственности (SRP)

Давайте предположим, что нам нужно изменить компонент views/Home.vue, чтобы получить список задач и показать их пользователю. Это может выглядеть так:

// views/Home.vue

<template>
  <div>
    <header class="header">
      <nav class="header-nav" />
      <div class="container">
        <h1>My todo list</h1>
      </div>
    </header>
    <main>
      <div class="container">
        <div class="todo-list">
          <div
            v-for="{ id, title, completed } in todos"
            :key="id"
            class="todo-list__task"
          >
            <span :class="{ 'todo-list__task--completed': completed }">
              {{ title }}
            </span>
          </div>
        </div>
      </div>
    </main>
  </div>
</template>


<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { ITodo } from '@/types'

@Component
export default class Home extends Vue {
  todos: ITodo[] = []
  
  mounted() {
    this.fetchTodos()
  }
  
  fetchTodos(): void {
    fetch('https://jsonplaceholder.typicode.com/todos/')
      .then(response => response.json())
      .then((todos: ITodo[]) => (this.todos = todos))
  }
}
</script>


<style lang="scss">
...
</style>
// types.ts

export interface ITodo {
  id: number
  userId: number
  title: string
  completed: boolean
}


По сути, мы создали все приложение в одном компоненте views/Home.vue. Здесь мы видим нарушение SRP (принцип единой ответственности), которое говорит нам: «У каждого компонента должна быть только одна причина для изменения».
В данном случае у нас может быть множество причин что бы изменить этот компонент:

  1. Например: метод fetchTodos(), который получает список задач.
    Для изменения этого метода могут быть разные причины: подключение библиотеки axios (вместо использования нативного fetch) или подключение любой другой библиотеки для работы с API, добавления других методов в этот метод и т. д.
  2. Необходимость добавления других компонентов в этот компонент: боковая панель, меню, нижний колонтитул и т. д.
  3. Необходимость в изменении существующих элементов: заголовок или список задач.

Мы описали по крайней мере три причины, по которым нам, возможно, потребуется изменить views/Home.vue. Настоящая проблема начинается тогда, когда приложение будет расти и меняться. Компонент будет становиться все больше и больше, что в итоге может привести нас к тому, что мы полностью потеряем контроль над этим компонентом. Мы можем избежать нарушения SRP, извлекая каждую причину в отдельный компонент, класс или функцию. Давайте сделаем рефакторинг.

Прежде всего, мы можем вынести метод fetchTodos() из компонента, создав новый класс Api, который мы поместим в файл api.ts

export class Api {
  private baseUrl: string = 'https://jsonplaceholder.typicode.com/'

  constructor(private url: string) {}

  async fetch() {
    const response = await fetch(`${this.baseUrl}${this.url}`)

    return await response.json()
  }
}

Теперь давайте мы вынесем заголовок в новый функциональный (чистый) компонент components/Header.vue

// components/Header.vue

<template functional>
  <header class="header">
    <nav class="header-nav" />
    <div class="container">
      <h1>{{ props.listName }}</h1>
    </div>
  </header>
</template>


<style lang="scss">
...
</style>

И наш последний шаг - это извлечение списка задач.

// views/Home.vue

<template>
  <div>
    <Header listName="My new todo list" />
    <main>
      <TodoList :todos="todos" />
    </main>
  </div>
</template>


<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { ITodo } from '@/types'
import { Api } from '@/api'
import Header from '@/components/Header.vue'
import TodoList from '@/components/TodoList.vue'

@Component({
  components: { Header, TodoList }
})

export default class Home extends Vue {
  todos: ITodo[] = []

  async mounted() {
    this.todos = await this.fetchTodos()
  }

  async fetchTodos(): Promise<ITodo[]> {
    const api = new Api('todos')
    return await api.fetch()
  }
}
</script>


<style lang="scss">
...
</style>
// components/TodoList.vue

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


<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import { ITodo } from '@/types'

@Component
export default class TodoList extends Vue {
  @Prop({ required: true, default: () => [] }) todos!: ITodo[]
}
</script>


<style lang="scss">
...
</style>

Теперь наш код во views/Home.vue выглядит чище и читабельнее.

Принцип открытости / закрытости (OCP)

Давайте посмотрим на наш новый компонент components/TodoList.vue немного ближе. Мы видим, что этот компонент берет список задач и создает их в виде карточек для представления наших задач. Однако что произойдет, если мы захотим поменять местами эти карты или даже показать наши задачи в виде таблицы, а не карт? Мы должны изменить (модифицировать) наш компонент. Сейчас это выглядит немного негибким. OCP (принцип открытости / закрытости) говорит нам: «Компоненты должны быть открыты для расширения, но закрыты для модификации». Давайте исправим это нарушение этого ��ринципа.

Мы можем использовать vue slots, чтобы сделать наш компонент components/TodoList.vue более гибким.

// components/TodoList.vue

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


<style lang="scss">
...
</style>

Теперь мы перенесем наш список в виде карточек в отдельный компонент components/TodoCard.vue

// components/TodoCard.vue

<template>
  <div class="todo-list__task">
    <span :class="{ 'todo-list__task--completed': todo.completed }">
      {{ todo.title }}
    </span>
  </div>
</template>


<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import { ITodo } from '@/types'

@Component
export default class TodoCard extends Vue {
  @Prop({ required: true }) todo!: ITodo
}
</script>


<style lang="scss">
...
</style>

Теперь мы обновляем views/Home.vue, который после обновления будет выглядеть вот так:

// views/Home.vue

<template>
  <div>
    <Header listName="My new todo list" />
    <main>
      <TodoList>
        <TodoCard v-for="todo in todos" :key="todo.id" :todo="todo" />
      </TodoList>
    </main>
  </div>
</template>


<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { ITodo } from '@/types'
import { Api } from '@/api'
import Header from '@/components/Header.vue'
import TodoList from '@/components/TodoList.vue'
import TodoCard from '@/components/TodoCard.vue'

@Component({
  components: { Header, TodoList, TodoCard }
})
export default class Home extends Vue {
  todos: ITodo[] = []
  
  async mounted() {
    this.todos = await this.fetchTodos()
  }
  
  async fetchTodos(): Promise<ITodo[]> {
    const api = new Api('todos')
  
    return await api.fetch()
  }
}
</script>


<style lang="scss">
...
</style>

Теперь мы легко можем заменить отображение наших задач д��угим компонентом.

Принцип подстановки Лисков (LSP)

Теперь давайте поработаем над нашим Api классом, который мы создали в api.ts, когда проходили главу Принцип единой ответственности (SRP)

Во-первых, мы переименуем и отрефакторим (изменим) наш Api класс. Мы переименуем его в BaseApi класс и перенесем в директорию api/BaseApi.ts

// api/BaseApi.ts

export class BaseApi {
  protected baseUrl: string = 'https://jsonplaceholder.typicode.com/'

  async fetch(url: string): Promise<any> {
    const response = await fetch(`${this.baseUrl}${url}`)

    return await response.json()
  }
}

Как вы можете видеть, класс BaseApi имеет метод fetch(), который принимает один аргумент url.

По каким-то причинам мы решили добавить библиотеку axios в наше приложение.

npm install --save axios


После добавления библиотеки axios, мы создаем новый класс AxiosApi, который является подклассом BaseApi в api/AxiosApi.ts

// api/AxiosApi.ts

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

export class AxiosApi extends BaseApi {
  constructor() {
    super()
  }

  async fetch({ url }): Promise<any> {
    const { data } = await axios.get(`${this.baseUrl}${url}`)

    return data
  }
}

Теперь, если мы заменим наш BaseApi (родительский класс) новым AxiosApi (подклассом BaseApi) в методе fetchTodos(), который находится во views/Home.vue

// views/Home.vue

import { AxiosApi } from '@/api/AxiosApi'
...

async fetchTodos(): Promise<ITodo[]> {  
  const api = new AxiosApi()  

  return await api.fetch('todos')
}

то это сломает наше приложение, потому что мы не следовали LSP: «При расширении класса помните, что вы должны иметь возможность п��редавать объекты подкласса вместо объектов родительского класса, не нарушая клиентский код»

Как вы могли заметить, мы передали объект в качестве аргумента в метод fetch() класса AxiosApi, но вместо него класс BaseApi принимает строку. В этом случае мы не можем безболезненно заменить подкласс родительским классом.

Давайте исправим это:

// api/AxiosApi.ts

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

export class AxiosApi extends BaseApi {
  constructor() {
    super()
  }

  async fetch(url: string): Promise<any> {
    const { data } = await axios.get(`${this.baseUrl}${url}`)

    return data
  }
}

Теперь мы можем использовать как BaseApi, так и AxiosApi.
Так же мы можем погрузиться еще глубже и улучшить наш код, создав класс Api в api/api.ts, который расширяет BaseClass и имеет приватного провайдера свойств.

// api/api.ts

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 fetch(url: string): Promise<any> {
    return await this.provider.fetch(url)
  }
}

Теперь методу fetchTodos(), который находится в компоненте views/Home.vue не нужно знать какая библиотека у нас используется. И теперь, за счет приватного поля provider в api/api.ts мы можем переключаться между разными реализациями Api.

Наш метод fetchTodos() теперь будет выглядеть вот так:

// views/Home.vue

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

...

async fetchTodos(): Promise<ITodo[]> {
  const api = new Api()

  return await api.fetch('todos')
}

Принцип отделения интерфейсов (ISP)

Сейчас мы показываем наши задачи в виде карточек. Теперь давайте добавим простой TodoRow.vue, который будет отображать наш список задач в виде списка

// components/TodoRow.vue

<template>
  <div class="todo-list__row">
    <span>{{ todo.id }}: </span>
    <span :class="{ 'todo-list__row--completed': todo.completed }">{{
      todo.title
    }}</span>
  </div>
</template>


<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import { ITodo } from '@/types'

@Component
export default class Home extends Vue {
  @Prop({ required: true }) todo!: ITodo
}
</script>


<style lang="scss">
...
</style>

И теперь, во views/Home.vue мы заменим компонент TodoCard.vue на TodoRow.vue

// views/Home.vue

<template>
  <div>
    <Header listName="My new todo list" />
    <main>
      <TodoList>
        <TodoRow v-for="todo in todos" :key="todo.id" :todo="todo" />
      </TodoList>
    </main>
  </div>
</template>


<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { ITodo } from '@/types'
import { Api } from '@/api/api'
import Header from '@/components/Header.vue'
import TodoList from '@/components/TodoList.vue'
import TodoCard from '@/components/TodoCard.vue'
import TodoRow from '@/components/TodoRow.vue'

@Component({
  components: { Header, TodoList, TodoCard, TodoRow }
})

export default class Home extends Vue {
  todos: ITodo[] = []
  
  async mounted() {
    this.todos = await this.fetchTodos()
  }
  
  async fetchTodos(): Promise<ITodo[]> {
    const api = new Api()
    
    return await api.fetch('todos')
  }
}
</script>


<style lang="scss">
...
</style>

Как вы можете заметить, мы отправляем весь объект todo в prop :todo, в TodoCard.vue и TodoRow.vue, где мы используем только часть этого объекта. Мы не используем свойства userId в обоих компонентах и свойство id в компоненте TodoCard.vue. Здесь мы нарушаем ISP: «Компоненты не должны зависеть от свойств и методов, которые они не используют».

Есть несколько способов исправить эту проблему:

  • Нарезать интерфейс Todo на несколько небольших интерфейсов
  • Передавать только используемые свойства компонентам

Давайте проведем рефакторинг нашего кода и используем функциональные (чистые) компоненты.

// views/Home.vue

<template>
  <div>
    <Header listName="My new todo list" />
    <main>
      <TodoList>
        <!--<TodoCard-->
        <!--v-for="{ id, title, completed } in todos"-->
        <!--:key="id"-->
        <!--:title="title"-->
        <!--:completed="completed"-->
        <!--/>-->
        <TodoRow
          v-for="{ id, title, completed } in todos"
          :key="id"
          :id="id"
          :title="title"
          :completed="completed"
        />
      </TodoList>
    </main>
  </div>
</template>


<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { ITodo } from '@/types'
import { Api } from '@/api/api'
import Header from '@/components/Header.vue'
import TodoList from '@/components/TodoList.vue'
import TodoCard from '@/components/TodoCard.vue'
import TodoRow from '@/components/TodoRow.vue'

@Component({
  components: { Header, TodoList, TodoCard, TodoRow }
})

export default class Home extends Vue {
  todos: ITodo[] = []
  
  async mounted() {
    this.todos = await this.fetch()
  }
  
  async fetch(): Promise<ITodo[]> {
    const api = new Api()
    return await api.fetch('todos')
  }
}
</script>


<style lang="scss">
...
</style>
// components/TodoCard.vue

<template functional>
  <div class="todo-list__task">
    <span :class="{ 'todo-list__task--completed': props.completed }">
      {{ props.title }}
    </span>
  </div>
</template>


<style lang="scss">
...
</style>
// components/TodoRow.vue

<template functional>
  <div class="todo-list__row">
    <span>{{ props.id }}: </span>
    <span :class="{ 'todo-list__row--completed': props.completed }">{{
      props.title
    }}</span>
  </div>
</template>


<style lang="scss">
...
</style>

Теперь это выглядит гораздо лучше и чище.

Принцип инверсии зависимостей (DIP)

DIP гласит: «Классы (компоненты) высокого уровня не должны зависеть от классов (компонентов) низкого уровня. Оба должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций».

Что такое классы высокого и низкого уровня:

  • Классы низкого уровня реализуют базовые операции, такие как работа с API.
  • Классы высокого уровня содержат сложную бизнес-логику, которая заставляет классы низкого уровня что-то делать.

Давайте вернемся к нашему классу Api и создадим для него новый интерфейс в types.ts

// types.ts

export interface IApi {
  fetch(url: string): Promise<any>
}

И теперь мы обновим все наши классы API в директории api/ и в компоненте views/Home.vue

// api/Api.ts

import { BaseApi } from '@/api/baseApi'
import { FetchApi } from '@/api/fetchApi'
import { AxiosApi } from '@/api/axiosApi'
import { IApi } from '@/types'

export class Api extends BaseApi implements IApi {
  private provider: any = new AxiosApi()

  async fetch(url: string): Promise<any> {
    return await this.provider.fetch(url)
  }
}
// api/AxiosApi.ts

import axios from 'axios'
import { BaseApi } from '@/api/baseApi'
import { IApi } from '@/types'

export class AxiosApi extends BaseApi implements IApi {
  constructor() {
    super()
  }
  async fetch(url: string): Promise<any> {
    const { data } = await axios.get(`${this.baseUrl}${url}`)
    return data
  }
}
// api/BaseApi.ts

import { IApi } from '@/types'

export class BaseApi implements IApi {
  protected baseUrl: string = 'https://jsonplaceholder.typicode.com/'
  async fetch(url: string): Promise<any> {}
}
// api/FetchApi.ts

import { BaseApi } from '@/api/baseApi'
import { IApi } from '@/types'

export class FetchApi extends BaseApi implements IApi {
  constructor() {
    super()
  }
  async fetch(url: string): Promise<any> {
    const response = await fetch(`${this.baseUrl}${url}`)
    return await response.json()
  }
}
// views/Home.vue

<template>
  <div>
    <Header listName="My new todo list" />
    <main>
      <TodoList>
        <!--<TodoCard-->
        <!--v-for="{ id, title, completed } in todos"-->
        <!--:key="id"-->
        <!--:title="title"-->
        <!--:completed="completed"-->
        <!--/>-->
        <TodoRow
          v-for="{ id, title, completed } in todos"
          :key="id"
          :id="id"
          :title="title"
          :completed="completed"
        />
      </TodoList>
    </main>
  </div>
</template>


<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { ITodo, IApi } from '@/types'
import { Api } from '@/api/api'
import Header from '@/components/Header.vue'
import TodoList from '@/components/TodoList.vue'
import TodoCard from '@/components/TodoCard.vue'
import TodoRow from '@/components/TodoRow.vue'


@Component({
  components: { Header, TodoList, TodoCard, TodoRow }
})

export default class Home extends Vue implements IApi {
  todos: ITodo[] = []

  async mounted() {
    this.todos = await this.fetch()
  }

  async fetch(): Promise<ITodo[]> {
    const api = new Api()

    return await api.fetch('todos')
  }
}
</script>


<style lang="scss">
...
</style>

Теперь наши низкоуровневые (api классы) и высокоуровневые (views/Home.vue) классы зависят от одного интерфейса. Направление исходной зависимости было инвертировано: низкоуровневые классы API теперь зависят от высокоуровневой абстракции (интерфейса) IApi.

Подведем итоги

В этой статье мы рассмотрели все принципы SOLID в приложении на Vue.js. Я надеюсь, что это поможет вам избежать некоторых архитектурных ошибок в ваших проектах и улучшить ваше понимание принципов SOLID.

Ссылка на репозиторий: https://github.com/NovoManu/SOLID-vue

Послесловие

Подписывайся на нас в социальных сетях:
Vue.js

Nuxt.js

Наши друзья uWebDesign: