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

Spread the love
  • 3
    Поделились

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

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

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

Давайте посмотрим на все эти принципы в реальном проекте Vue.JS и как мы можем избежать нарушений принципов. Мы собираемся создать простое приложение со списком Todo.

Прим. переводчика: более подробнее о SOLID можно почитать здесь:


Предварительные условие

Давайте создадим новое приложение Vue.JS, используя vue cli (https://cli.vuejs.org/).

vue create todo-app

В нашем приложении я собираюсь использовать vue 2.6.10 + typescript 3.4.3, поэтому, если вы еще не знакомы с typescript, вы можете найти документацию здесь(https://www.typescriptlang.org/docs/home.html). Так же нужно будет выбрать опцию установки с vue-router. Так же нужно будет проверить наличие установленных библиотек: node-sass, sass-loader

После установки нам нужно немного почистить и удалить все демонстрационные компоненты. Наша структура каталогов src выглядит следующим образом

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

Содержимое файла main.ts

import Vue from 'vue';
import App from './App.vue';
import router from "./router";

Vue.config.productionTip = false;

new Vue({
  router,
  render: (h) => h(App),
}).$mount('#app');

Содержимое файла router.ts

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";

Vue.use(Router);

export default new Router({
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    }
  ]
});

Содержимое файла App.vue

<template>
  <div id="app">
    <router-view />
  </div>
</template>
<style lang="scss">
body {
  margin: 0;
  padding: 0;
}
</style>

Файл 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, чтобы получить список задач и показать их пользователю. Это может выглядеть так:

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">
.header {
  width: 100%;
  &-nav {
    background: teal;
    width: 100%;
    height: 56px;
  }
}
.container {
  padding: 1.5rem;
}
.todo-list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: stretch;
  &__task {
    width: 24%;
    padding: 1.5rem;
    margin: 0.5%;
    text-align: left;
    color: #4169e1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
    transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
    &:hover {
      box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
        0 10px 10px rgba(0, 0, 0, 0.22);
    }
    &--completed {
      color: #2e8b57;
      text-decoration: line-through;
    }
  }
}
</style>

types.ts

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

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

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

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

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

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

Header.vue

<template functional>
  <header class="header">
    <nav class="header-nav" />
    <div class="container">
      <h1>{{ props.listName }}</h1>
    </div>
  </header>
</template>
<style lang="scss" scoped>
.header {
  width: 100%;
  &-nav {
    background: teal;
    width: 100%;
    height: 56px;
  }
}
</style>

Создадим новый компонент 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" scoped>
.todo-list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: stretch;
  &__task {
    width: 24%;
    padding: 1.5rem;
    margin: 0.5%;
    text-align: left;
    color: #4169e1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
    transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
    &:hover {
      box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
        0 10px 10px rgba(0, 0, 0, 0.22);
    }
    &--completed {
      color: #2e8b57;
      text-decoration: line-through;
    }
  }
}
</style>

И наш последний шаг – изменения в Home.vue

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">
.container {
  padding: 1.5rem;
}
</style>

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


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

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

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

TodoList.vue

<template functional>
  <div class="container">
    <div class="todo-list">
      <slot />
    </div>
  </div>
</template>
<style lang="scss" scoped>
.todo-list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: stretch;
}
</style>

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

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" scoped>
.todo-list {
  &__task {
    width: 24%;
    padding: 1.5rem;
    margin: 0.5%;
    text-align: left;
    color: #4169e1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
    transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
    &:hover {
      box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
        0 10px 10px rgba(0, 0, 0, 0.22);
    }
    &--completed {
      color: #2e8b57;
      text-decoration: line-through;
    }
  }
}
</style>

И далее обновим 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">
.container {
  padding: 1.5rem;
}
</style>

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


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

Давайте далее рассмотрим наш класс Api.

Во-первых, мы переименуем и реорганизуем класс Api в класс BaseApi и перемещаем его в отдельный каталог api/BaseApi.ts

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

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

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 (подкласс) в методе fetchTodos() в 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 принимает строку. В этом случае мы не можем безболезненно заменить подкласс родительским классом.

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.ts

import { BaseApi } from '@/api/BaseApi'
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 не нужно знать, какую библиотеку мы используем, и мы можем легко переключать провайдера в классе Api.

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

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

Сейчас мы визуализируем наши задачи в виде карточек. Давайте также добавим простой список задач в components/TodoRow.vue

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">
.todo-list {
  &__row {
    width: 100%;
    text-align: left;
    color: #4169e1;
    &--completed {
      text-decoration: line-through;
      color: #2e8b57;
    }
  }
}
</style>

и заменим визуализацию карточками на визуализацию списком

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">
.container {
  padding: 1.5rem;
}
</style>

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

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

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

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

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">
.container {
  padding: 1.5rem;
}
</style>

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" scoped>
.todo-list {
  &__task {
    width: 24%;
    padding: 1.5rem;
    margin: 0.5%;
    text-align: left;
    color: #4169e1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
    transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);

    &:hover {
      box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
        0 10px 10px rgba(0, 0, 0, 0.22);
    }

    &--completed {
      color: #2e8b57;
      text-decoration: line-through;
    }
  }
}
</style>

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">
.todo-list {
  &__row {
    width: 100%;
    text-align: left;
    color: #4169e1;

    &--completed {
      text-decoration: line-through;
      color: #2e8b57;
    }
  }
}
</style>

Теперь все выглядит лучше.


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

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

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

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

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

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

и обновим все наши классы API в каталоге api и views/Home.vue.

api.ts

import { BaseApi } from '@/api/BaseApi'
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)
  }

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
  }
}

BaseApi.ts

import { IApi } from '@/types'

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

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()
  }
}

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">
.container {
  padding: 1.5rem;
}
</style>

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


Заключение

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

Код можно найти здесь  https://github.com/NovoManu/SOLID-vue

Удачи!

Оригинальная статья Manu Ustenko: How to avoid SOLID principles violations in Vue. JS application


Spread the love
  • 3
    Поделились

Добавить комментарий

Ваш e-mail не будет опубликован.