HomeAuthorContactSearch

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>