在这篇文章中,我将讨论如何在 Vue 应用中使用 SOLID 原则。
SOLID 包括以下观点:
接下来我们看看如何在 Vue 实战中避免这些原则,我们从一个 TODO LIST 项目中去体会这些观点。
准备
用 vue cli 初始化一个 Vue 项目。
vue create todo-app
我们用 vue2.6.10 + typescript3.4.3 构建我们的应用。如果你对 typescript 不熟悉,可以查看typescriptlang。
安装完成之后,将目录中的组件都删除掉,然后我们的 src 目录如下图所示:
// App.vue
<template>
<div>
App Page
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class App extends Vue {}
</script>
// views/Home.vue
<template>
<div>
Home Page
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class Home extends Vue {}
</script>
准备工作就绪,接下来正式进入正题。
单一职责原则(SRP)
首先我们将 views/Home.vue 组件改成如下代码,通过API获取一个任务列表并展示出来:
// 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>
基本上所有的功能我们都在 views/Home.vue 中完成了。单一职责原则规定的是一个类应该有且仅有一个引起变化的原因,否则应该被拆分。接下来我们看看有多少个理由可以改变我们的组件:
- 我们想要改变请求函数
fetchTodos来获取待办事项:用其他库比如 axios、api、方法本身等; - 我们想要添加更多的元素,比如sidebar、menu、footer等等;
- 我们想要修改已存在的元素:header 或者 todo list;
如上面所述,我们轻易地找到3条理由去修改这个 views/Home.vue。
当这个应用的功能越来越丰富时,真正的问题将开始:代码越来越多直到我们都不知道自己写了什么(这就意味着该组件失去了控制)。
通过将上述可能存在的变动提取到不同的函数、类或者组件中,我们就可以避免违反单一职责原则。接下来进行重构:
第一步,将我们的请求函数放到新的API文件中(新建 src\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()
}
}
第二步,我们将 header 组件提取成一个新的函数组件 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>
最后一步,我们提取 TODO LIST 组件 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" 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:
<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>
重构之后,可以看到我们的 Home 组件更加的单一和可读。
开闭原则(OCP)
让我们把目光聚焦在 components/TodoList.vue。这个组件展示了一系列待做任务卡片。如果需求变更,我们要改变这些卡片或者将它们用表格的形式展示,会发生什么?答案是我们必须修改这个组件(它看起来非常死板)。开闭原则规定“当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。”现在我们来重构 TodoList 组件,达到避免这种窘境!
我们通过插槽来改变这个情况:
<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>
最后更新我们的 Home 组件:
<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.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 库。
yarn add axios
然后创建一个 BaseApi 的子类 AxiosApi(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
}
}
如果我们要将 views/Home.vue 的 BaseApi(父类)类用 AxiosApi(子类)替换掉:
import { AxiosApi } from '@/api/AxiosApi'
...
async fetchTodos(): Promise<ITodo[]> {
const api = new AxiosApi()
return await api.fetch('todos')
}
这样做已经破坏了我们的应用,因为我们没有遵从里氏原则:“子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。”
如你所见,我们将对象作为参数传递给 AxiosApi 类的 fetch()方法,但是 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 类来控制子类。
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)
}
}
接口隔离原则(ISP)
我们将任务可视为卡片。让我们在 components/TodoRaw.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>
如你所见,我们在 TodoCard.vue 和 TodoRow.vue 中将整个 todo 对象作为 prop 发送,但是我们仅使用此对象的一部分。userId 在两个组件中都没用到,id 仅在 TodoCard.vue 中使用。我们这就违反了接口隔离原则“组件不应该依赖没有使用到的属性和方法”。
有两种方式可以解决这个问题:
- 将 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">
.container {
padding: 1.5rem;
}
</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" 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>
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">
.todo-list {
&__row {
width: 100%;
text-align: left;
color: #4169e1;
&--completed {
text-decoration: line-through;
color: #2e8b57;
}
}
}
</style>
依赖倒置原则(DIP)
依赖倒置原则的原始定义为:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
高层模块和低层模块是什么意思?
- 低层模块实现最基本的操作,例如API;
- 高层模块包含复杂的业务逻辑,该逻辑指导低层模块执行某项操作。
我们在 types 中为 Api 类创建一个新的接口:
export interface IApi {
fetch(url: string): Promise<any>
}
接着更新我们所有的 api 类和 views/Home.vue:
更新 api/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)
}
}
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> {}
}
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">
.container {
padding: 1.5rem;
}
</style>
现在,低级类 Api 和高级 views/Home.vue 类依赖同一个接口 IApi。原始依赖关系的方向已经颠倒了:低级 Api 现在依赖于高级抽象。