如何避免在Vue中违反SOLID原则

385 阅读8分钟

封面图:黄梦莹

前言

本文为转载外加意译,并重新排版。

几天前,我的朋友菲利普(Philipp)决定创业(一家面包店,用来烹制很棒的饼干),并请我帮助他创建一个待办事项列表应用程序,以便他可以记录他的工作。

他决定用Vue.JS作为前端框架,但实际上不知道将来如何使他的应用易于扩展和维护。 他听到了一些关于SOLID (SOLID 是啥???)原理和清晰结构体系的神话,但不知道如何在Vue中使用它们。

在本文中,我想讨论如何在Vue.JS项目中避免违反SOLID原则。

啥是SOLID?

SOLID是Michael Feathers提出的面向对象编程的五大软件设计原则首字母缩写 。

Robert Cecil Martin (又叫“鲍勃大爷”)在他的《设计原则与设计模式》一书中进行了推广。(《Design Principles and Design Patterns》)

这五个原则是面向对象编程范式中非常重要的一部分,旨在使我们的程序更灵活、更可读、更易于维护,以便下一步开发。

SOLID原则包括如下概念:

  • 单一责任原则
  • 开闭原则
  • 里氏替换原则
  • 接口隔离原则
  • 依赖倒置原则

让我们看看实际的Vue.JS项目中如何应用这些原则,以及如何避免违反原则。我们将创建简单的待办事项列表应用程序。让我们在实际的Vue.JS项目中了解所有这些原则,以及如何避免违反原则。我们将创建简单的待办事项列表应用程序 。

先决条件

让我们使用vue cli(cli.vuejs.org/)创建一个新的Vue.…

vue create todo-app

在我们的应用中,我将使用vue 2.6.10 + typescript 3.4.3,因此,如果您还不熟悉 typescript ,可以在此处找到文档(www.typescriptlang.org/docs/home.h… )。

安装后,我们必须清理一下并删除所有演示组件。 我们的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>
<!-- 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>
//type.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。

让我们做一些重构

首先,我们可以通过创建一个新的Api类提取提取todos方法并将其保存到 **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()
  }
}

接下来,让我们将header提取到 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>

我们的最后一步是提取待办事项列表。

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

现在,views/Home.vue中的代码看起来更加清晰易读。

开闭原则(OCP)

让我们更仔细地看一下我们的 **components/TodoList.vue **。我们可以看到,该组件获取待办事项列表并创建了一堆卡片来代表我们的任务。但是,如果我们要更换这些卡片,甚至将任务显示为表格而不是卡片,会发生什么?我们必须更改(修改)我们的组件。现在看起来有点不灵活。

OCP告诉我们:“组件应该是对于扩展开放,但是对于修改封闭 ”。

让我们解决此冲突

我们可以使用 vueslots 来使 **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

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

如果我们在 **fetchTodos() **方法中使用新的 AxiosApi(子类)替换 BaseApi(父类) views/Home.vue

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

它会破坏我们的应用程序,因为我们没有遵循LSP:“When extending a class, remember that you should be able to pass objects of the subclass in place of objects of the parent class without breaking the client code ”。(我觉得这句更好理解:某个对象实例的子类实例应当可以在不影响程序正确性的基础上替换它们 )

如您所见,我们将对象作为参数传递给 **AxiosApi **类的 fetch() 方法,但是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 { 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)
  }
}

现在,views/Home.vue中的 fetchTodos() 方法不需要知道我们使用的是哪个库,我们可以在 **Api **类中轻松地切换 fetch 功能提供者。

//Home.vue

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>

如您所见,我们在 **TodoCard.vue **和 **TodoRow.vue **中将整个 **todo **对象作为 **prop **发送,但是我们仅使用此对象的一部分。我们在两个组件中都没有使用 **userId **和 id 属性。我们违反了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 **类,并在 **types.ts **中为 **Api **类创建一个新接口 。

//type.ts

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

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

并更新 **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)
}
//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类现在依赖于高级抽象。

结论

在本文中,我们研究了小型Vue.JS项目中的所有SOLID原理。我希望它可以帮助您避免项目中的一些架构错误并增进您对SOLID原理的理解。

可以在这里找到代码github.com/NovoManu/SO…

祝好运!

帮助理解的扩展阅读