Vue应用设计模式
过去的两个月,我一直在思考如何合理有效地组织大型Vue项目,每次总结出一套模式,刚好有项目来给我练手。经过反复的锤炼,终于总结出一套适合我当前业务领域的单页应用设计模式。
我非常依赖代码提示和各种类型检查,所以我一直是Ts的忠实支持者。不得不说,Vue的Ts支持很差,虽然在Vue中同样可以使用TSX,但这毕竟不是习惯的用法,模板语法本身也非常好用,Ts支持差和Vue本身的机制有关系。
Vue3.0全部使用Ts编写,目前看来以后的主流会是类似于React Hooks的Function Base API,Ts支持应该会变得很好,当下推荐使用vue-property-decorator
配合反射编写组件,使用Vue CLI创建适用于生产环境的Ts项目即可。
初始化一个 Todo List Demo
假如现在需要做一个Todo List系统,由一个暂时静态信息的主页,一个可以操作待办事项的页面组成。使用Vue CLI 3.x创建的项目,初始目录为:
├── public
└── src
├── assets
├── components
└── views
删除App.vue
内无关的样式
<template>
<div>
<router-view/>
</div>
</template>
路由和布局
使用普通vue-router的配置前篇一律,nuxt提出的自动生成路由与布局用起来非常顺手。使用Vue CLI创建的项目可以通过添加插件来实现相同的功能。
$ vue add auto-routing
├── public
└── src
├── assets
├── components
- ├── views
+ ├── layouts
+ └── pages
编辑pages/index.vue
即承载首页的单文件组件,保存后即可看到效果。
新增的布局和页面组件并不在webpack的watch范围内,在任意其他文件内使用保存命令触发热重载后,重新保存新增的文件即可。
<template>
<div>Hello World</div>
</template>
使用auto-routing
插件将自动创建默认的布局文件,使用方法与nuxt布局完全相同。在默认布局布局中添加导航栏:
<template>
<div>
<nav class="nav">
<router-link to="/">Home</router-link>
<router-link to="/todo">Todo</router-link>
</nav>
<router-view />
</div>
</template>
<style lang="scss" scoped>
.nav {
text-align: center;
padding: 30px;
a {
font-weight: bold;
color: #2c3e50;
text-decoration: none;
&.router-link-exact-active {
color: #42b983;
}
}
a + a {
margin-left: 1rem;
}
}
</style>
模块化
模块化是一定要的,模块化的好处我就不做过多的介绍了。由于我以前深陷于传统Web的开发,自然受到了影响,所以理所当然以页面划分模块。这样做带来最直接的好处就是不需要我太了解业务,就能直接上手开发。
模块的状态,服务,组件全都随着页面的划分而自然的分开。然而随着应用逐渐复杂,出现了一个尴尬的问题:不同页面之间组件/状态/服务(以下统称依赖)的共享。我总是面临这样的选择:**将依赖提升至父模块或者干脆什么都不管直接拉过来用。**提升依赖会带来巨大的工作量,如果某个模块的依赖复用了另一个模块的依赖,很需要连着好几个依赖一起提升,多来几趟甚至都能重构项目了。如果直接拉来调用,代码会变得难以阅读和维护,错综复杂的调用链分分钟让人崩溃。
使用这种模式,我的效率不但没有上升,反而浪费了很多时间在提升依赖上。重复的工作做多了,总是会烦,使用这种模式让我痛苦万分,可能是我经历过最差的编程体验了。
从Angular中学到了什么?
不得不说Angular是目前最适合工程化的框架,学习曲线陡峭,门槛偏高。但对于经验丰富的,尤其是有AOP编程经验的开发者来说,上手速度还算可观。有一段时间我在百度工作,团队使用Angular,在学习期间,最让我感到疑惑的是:如何划分Angular模块?
**
Angular本身有许多功能模块和抽象模块,一些组件库将以组件划分模块,实际业务又以路由划分模块。模块划分完成后,模块的依赖又要如何划分?有很多依赖直觉上可以存在多个模块中,应该怎么精准划分?
实际上,到我离职这些问题也没能解决,因为大家根本不关心这些,只关心怎么才能把业务代码搞定。在我看来,这样的团队使用Angular根本就是在浪费时间,只是在写着语法而已。
在查阅了很多文章后,我发现Angular的开发者们对模块的划分似乎都有不同的理解,最多的声音是我开始提到的,以路由划分模块,Angular官方似乎也没给出他们的最佳实践。
不过Angular仍然是最有学习价值的前端框架,它提出的工程化建议和代码风格都是非常优秀的。
划分模块的新想法
某一天我在写服务端代码的时候突然想:是否前端可以像服务端一样,对业务进行建模,抽象出与业务强相关的模块?
**
可以想象,如果这样划分模块,有很多问题可以迎刃而解。由于模块的扁平化,所有模块的依赖都可以随心所欲地调用,复用也变得更加合理,代码可读性大大增强。路由作为系统级的模块单独存在,页面由各个业务模块提供的组件拼接而成,不再需要拆分。
这种划分方式最大的难点在于:**如何对业务进行准确的抽象。**建模和抽象在编程领域中是最上层的建筑,可能大部分码农不会想,也没有机会去想。这方面的书籍也是少之又少,好像在这一方面并没有通用的解决方案或提升途径,只能靠开发人员长期的经验积累和技术沉淀。
在实际的项目中我发现,如果接口严格遵循Restful,服务端开发人员负责且技术到位,那么模块完全可以按照接口来分。
Todo List的例子
我们应该将Todo抽象为一个模块,模块中含有list的状态,操作list的适配层以及与list有关的组件。如果后台给出接口,那么接口应该是以/todo
为前缀的。
src
├── assets
├── components
├── layouts
+ ├── modules
+ │ └── todo
└── pages
接口类型
我习惯首先在模块下创建一个文件,将与模块强相关的接口写进这个文件中。类型提取统一处理是一个常见的做法,类似于声明,开发者可以根据这个接口文件了解此模块的职能。
├── modules
│ └── todo
+ │ └── todo.interface.ts
状态管理
Vuex实在是很好用的工具,它本身也提供模块化的接口。我偏向于将与模块强相关的状态划分到某一模块下,这些状态将作为系统的一部分。
模块注册
在todo文件夹下新增todo.store.ts
用于编写todo模块的store。
├── modules
│ └── todo
│ ├── todo.interface.ts
+ │ └── todo.store.ts
import Store from '@/store';
Store.registerModule('todo', {
state: {
todoListLoading: false, // 表示Todo列表的加载状态
todoList: [], // Todo列表
},
});
我们也可以给state指定类型,实现类型检查,在todo.interface.ts
中导出Todo类型:
export interface Todo {
text: string;
createdAt: string;
};
export interface TodoStoreState {
todoList: Todo[];
todoListLoading: boolean;
};
为state指定类型
import Store from '@/store';
import { TodoStoreState } from './todo.interface';
Store.registerModule<TodoStoreState>('todo', {
state: {
todoListLoading: false, // 表示Todo列表的加载状态
todoList: [], // Todo列表
},
});
编写语义化的mutations
使用vue-devtool
可以追踪store的所有变化,使用语义化的mutations可以帮助开发者更直观地了解store的变化情况。
import Store from '@/store';
import { TodoStoreState } from './todo.interface';
import { TodoService } from './todo.service';
Store.registerModule<TodoStoreState>('todo', {
namespaced: true,
state: {
todoListLoading: false, // 表示Todo列表的加载状态
todoList: [], // Todo列表
},
mutations: {
setTodoList(state, payload) {
state.todoList = payload
},
setTodoListLoading(state, payload) {
state.todoListLoading = payload
},
}
})
这些模板代码可以帮助我们在发生问题的时候快速定位BUG,发生问题或重构代码时,全局搜索某个mutation的名字可以直接定位到使用它们的位置,mutation的名字足够特殊,几乎没有重名的可能。
在阅读代码时,也可以清晰的了解组件是如何控制状态改变的,随着项目越来越大,这个特性尤其重要。
适配层
将数据层和视图层分离开是良好的习惯。如果页面逻辑与接口提供的数据深深交织在一起,修改或重构将是一个痛苦的过程,更别提其他开发者来维护代码了。
我们将与模块强相关的数据以service的形式添加至模块下。
├── modules
│ └── todo
│ ├── todo.interface.ts
+ │ ├── todo.service.ts
│ └── todo.store.ts
import { Todo } from './todo.interface';
export class TodoService {
public static async getTodoList(): Promise<Todo[]> {
return new Promise<Todo[]>((resolve, reject) => {
setTimeout(() => {
resolve([
{
text: 'A new todo',
createdAt: new Date(),
},
])
}, 1000)
})
}
}
service应有明确的返回类型,接口返回数据发生变化时,仅需要修改适配层即可。适配层的理想返回值应是视图层直接可用的,所以视图层不应该做任何的数据处理。
有了数据支持,store的action也可以补全了
Store.registerModule<TodoStoreState>('todo', {
actions: {
async loadTodoList({ commit }) {
commit('setTodoListLoading', true);
const todoList = await TodoService.getTodoList();
commit('setTodoList', todoList);
commit('setTodoListLoading', false);
},
},
})
单文件组件
同样的,与模块强相关的组件也应该放置在模块文件夹下,组件可以直接获取,修改store中的状态。 组件的命名以Vue官方的风格建议为准。
在我们的例子中,需要一个展示所有待办事项的列表组件
├── modules
│ └── todo
+ │ ├── TodoList.vue
│ ├── todo.interface.ts
│ ├── todo.service.ts
│ └── todo.store.ts
有很多种方式可以获取到store中的状态,推荐使用vuex-class
。
$ npm install vuex-class
在todo.store.ts
中导出namespace
import { namespace } from 'vuex-class';
...
export const TodoStore = namespace('todo');
引用state编写组件
<template>
<div>
<p v-if='todoListLoading'>Loading...</p>
<ul v-else>
<li v-for="(item, i) in todoList" :key="i">{{item.text}}</li>
</ul>
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import { TodoStore } from './todo.store';
import { Todo } from './todo.interface';
@Component
export default class TodoList extends Vue {
@TodoStore.State todoList!: Todo[];
@TodoStore.State todoListLoading!: boolean;
}
</script>
加载状态
加载store内的状态很简单,关键是我们应该何时加载这些状态。这属于一个优化问题,做得好可以大大提升用户体验,做的不好则三步一loading。
状态加载的艺术
好产品和烂产品的区别就体现在细节上,状态需要在特定的时机加载。常见的有应用创建后,页面创建前后,组件创建前后,路由进入前后等。一般来说,为了节省带宽,一些低频的状态可以懒加载,同一页面要用到的状态可以放到一起加载,高频且不会频繁发生变化的状态可以放到应用创建时加载。
在Todo List中,用户一定会加载todo状态。所以我们在应用创建后直接加载todoList状态,尽量不要让用户看到加载页面。
<!-- App.vue -->
<template>
<router-view></router-view>
</template>
<script lang="ts">
import { Vue, Component } from "vue-property-decorator";
import { TodoStore } from "./modules/todo/todo.store";
@Component
export default class App extends Vue {
@TodoStore.Action loadTodoList!: () => void;
created() {
this.loadTodoList();
}
}
</script>
<style lang="scss" scoped>
</style>
最后,在pages内新增todo.vue
文件并引入组件
<template>
<div>
<todo-list></todo-list>
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import TodoList from '@/modules/todo/TodoList.vue';
import { TodoStore } from '@/modules/todo/todo.store';
@Component({
components: {
TodoList,
},
})
export default class TodoPage extends Vue {}
</script>