В этой статье я хотел бы обсудить, как мы можем избежать нарушения принципов SOLID в нашем проекте Vue.JS.
Что такое SOLID? SOLID — это аббревиатура, созданная Майклом Фезерсом и продвигаемая американским инженером-программистом Робертом С. «Дядя Боб» Мартином в его книге «Принципы проектирования и шаблоны проектирования». Эти принципы являются очень важной частью парадигмы объектно-ориентированного программирования, предназначенной для того, чтобы сделать нашу программу более гибкой, читаемой и поддерживаемой для последующей разработки. Принципы SOLID включают следующие понятия:
Давайте посмотрим на все эти принципы в реальном проекте 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>
Теперь мы готовы идти дальше…
Давайте предположим, что нам нужно изменить компонент 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"> .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>
export interface ITodo { id: number userId: number title: string completed: boolean }
По сути, мы создали все приложение целиком в одном компоненте views/Home.vue. Здесь мы видим нарушение SRP, которое говорит нам: «У каждого компонента должна быть только одна причина для изменения». В данном случае у нам может быть множество причин что бы изменить этот компонент:
Мы описали по крайней мере три причины, по которым нам возможно потребуется изменить 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
<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
<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 теперь выглядит чище и читабельнее.
Давайте посмотрим на наш новый компонент components/TodoList.vue немного ближе. Мы видим, что этот компонент берет список задач и создает связку карточек для представления наших задач. Однако что произойдет, если мы захотим поменять эти карты или даже показать наши задачи в виде таблицы, а не карт? Мы должны изменить (модифицировать) наш компонент. Сейчас это выглядит немного негибким. OCP говорит нам: «Компоненты должны быть открыты для расширения, но закрыты для модификации». Давайте исправим это нарушение.
Мы можем использовать слоты vue, чтобы сделать наши components/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
<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>
Теперь мы можем легко заменить визуализацию наших задач другим компонентом.
Давайте далее рассмотрим наш класс Api.
Во-первых, мы переименуем и реорганизуем класс Api в класс BaseApi и перемещаем его в отдельный каталог 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
И создадим новый класс AxiosApi, который будет подклассом BaseApi в 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 (подкласс) в методе 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 принимает строку. В этом случае мы не можем безболезненно заменить подкласс родительским классом.
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 и имеет приватного провайдера свойств.
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') }
Сейчас мы визуализируем наши задачи в виде карточек. Давайте также добавим простой список задач в 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"> .todo-list { &__row { width: 100%; text-align: left; color: #4169e1; &--completed { text-decoration: line-through; color: #2e8b57; } } } </style>
и заменим визуализацию карточками на визуализацию списком
<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: «Компоненты не должны зависеть от свойств и методов, которые они не используют».
Есть несколько способов исправить эту проблему:
Давайте проведем рефакторинг нашего кода и используем функциональные (не сохраняющие состояние) компоненты.
<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>
<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>
<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 говорит: «Классы (компоненты) высокого уровня не должны зависеть от классов (компонентов) низкого уровня. Оба должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций ».
Что такое классы высокого и низкого уровня.
Давайте вернемся к нашему классу Api и создадим новый интерфейс для нашего класса Api в types.ts.
export interface IApi {
fetch(url: string): Promise<any>
}
и обновим все наши классы API в каталоге api и views/Home.vue.
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) }
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 } }
import { IApi } from '@/types' export class BaseApi implements IApi { protected baseUrl: string = 'https://jsonplaceholder.typicode.com/' async fetch(url: string): Promise<any> {} }
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() } }
<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
Краткий перевод: https://vuejs.org/guide/components/v-model.html Основное использование v-model используется для реализации двусторонней привязки в компоненте. Начиная с Vue…
Сегодня мы рады объявить о выпуске Vue 3.4 «🏀 Slam Dunk»! Этот выпуск включает в…
Vue.js — это универсальный и адаптируемый фреймворк. Благодаря своей отличительной архитектуре и системе реактивности Vue…
Недавно, у меня истек сертификат и пришлось заказывать новый и затем устанавливать на хостинг с…
Каким бы ни было ваше мнение о JavaScript, но всем известно, что работа с датами…
Все, кто следит за последними событиями в мире адаптивного дизайна, согласятся, что введение контейнерных запросов…
View Comments
Отличная статья!
Хорошая статья, но не совсем понятно, зачем Home и провайдеры в Api (Axios и Fetch) реализуют IApi. Home не должен меняться, если в интерфейс IApi добавится какой-то метод. Он вообще не обязан иметь метод fetch, это просто совпадение. Axios и Fetch также не должны следовать интерфейсу для Api, у них вполне мог бы быть свой интерфейс (а'ля IApiProvider).
Если я где-то не прав, поправьте меня, если не сложно.
Жаль, что на комменты не отвечают, а так рад что наткнулся на этот блог. Отличный перевод и статья актуальная!
Отвечают, когда есть что ответить. А так некоторые вопросы относятся скорее к авторам статей (оригинальный статей), поэтому иногда лучше напрямую у них спрашивать.
Пункт про ISP - полный бред. Каким образом компонент зависит от неиспользуемого свойства? Если в будущем это свойство понадобится, тогда что?
в будущем и добавишь, если оно понадобиться.
Круто! Так это нарушает принцип О. Мне нужно будет лезть во все родительские компоненты и прокидывать это свойство. Если мы передаём весь объект, это свойство лежит по ссылке и никому не мешает