阅读 784

使用Vue3 composition-api重写一个抽象可复用的增删改查页面

vue3.0 beta版本已经发布一段时间了,尝试着用composition-api来重写一个简单的后台管理系统中的增删改查。

对于常用的增删改查的后台管理页面,通常的为表格+详情页的模式,主要包含如下几个功能:

  • 表格用于展示数据内容
  • 点击表格中的一项,能够弹出详情页进行编辑。
  • 保存或取消更新表格数据。

这种常用的模式在vue2下,这次试着用vue3的api来改写,让我们抛弃被吐槽了很多的mixin,拥抱hooks。

使用@vue/cli创建项目

首先确认@vue/cli为最新版本4.3.1,否则升级的时候可能会出现错误,执行:

vue create vue3-admin-demo
cd vue3-admin-demo
vue add vue-next
复制代码

安装后检查package.json中的vue版本为3.0.0就成功了,注意创建的时候,如果有vuex及vue-router,需要先手动勾选后,再执行vue add vue-next,vuex及vue-router便会自动升级到4.0版本。

写一个简单的管理页面

初始化

main.js中初始化vue的方式也有了区别,vue不再通过export default 方式暴露,而是使用对应的api,这里使用vuex及vue-router的use引入也是用类似链式调用的方式,如下:

import { createApp } from 'vue';
import App from './App.vue'
import router from './router'
import store from './store'
createApp(App).use(router).use(store).mount('#app')
复制代码

引入vue-router后,App.vue便可以直接使用router-link,我们在默认的模板里增加一条路由信息:

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/staff">Staff</router-link>
    </div>
    <router-view/>
  </div>
</template>

复制代码

创建路由

views目录创建staff.vue的页面,然后去router/index.js更新一下路由:

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  },
  {
    path: '/staff',
    name: 'Staff',
    component: () => import(/* webpackChunkName: "about" */ '../views/Staff.vue')
  }
]
复制代码

在Staff.vue中随便写点什么,预览一下:

OK,路由准备完成,下面开始编写内部表格

编写页面模板

这里我们简单定义数据为一个人员列表,结构如下,这里跟vue2没有太大区别。

<div class="home">
    <button>新增</button>
    <table>
      <thead>
        <th>姓名</th>
        <th>部门</th>
        <th>职位</th>
        <th>入职日期</th>
        <th>操作</th>
      </thead>
      <tbody>
        <tr>
          <td>name</td>
          <td>department</td>
          <td>position</td>
          <td>date</td>
          <td>
            <button>编辑</button>
            <button>删除</button>
          </td>
        </tr>
      </tbody>
    </table>
    <div class="dialog-detail" v-show="isShowDetail">
      <div class="form-item">
        <span class="label">姓名:</span>
        <input type="text" v-model="state.form.name">
      </div>
      <div class="form-item">
        <span class="label">部门:</span>
        <input type="text" v-model="state.form.department">
      </div>
      <div class="form-item">
        <span class="label">职位:</span>
         <input type="text" v-model="state.form.position">
      </div>
      <div class="form-item">
        <span class="label">入职日期:</span>
         <input type="text" v-model="state.form.date">
      </div>
      <div class="btn-group">
        <button @click="confirmItem(state.form)">确认</button>
        <button @click="cancelEdit">取消</button>
      </div>
    </div>
    
</html>
复制代码

js部分采用setupAPI进行改写,Vue3中使用refreactive来定义响应式对象,其中ref可以定义简单的数字,字符串等变量,例如

import { ref } from 'vue'
const count = ref(0);
const isCancel = ref(true);
复制代码

如果需要对复杂类型的变量如object之类的,就需要用reactive方法来进行定义,我们先定义一个form对象,用于绑定详情页的数据,用于后续编辑和新增。

import { reactive } from 'vue';
import { usePageData } from '../components/PageData'; 
import { fetchStaff } from '../api';
export default {
  name: 'Staff',
  setup() {
    // form对象用于v-model绑定
    const state = reactive({
      form: {
        name: '',
        department: '',
        position: '',
        date: ''
      }
    })
    const pageData = usePageData(fetchStaff, state.form);
    return {
      state,
      ...pageData,
    }
  }
}
复制代码

这里看到了使用了一个usePageDatafetchStaff,是实现的关键,

复用逻辑抽象

可以看到,staff.vue的完整代码比较简洁,我们考虑到:未来需要拓展的话,不同表格,只要定义标准API返回的数据格式,对于获取数据查看详情, 编辑确认取消,之类的逻辑几乎是一模一样的,对于不同页面来说,只有api地址不同,其他逻辑都可以复用和抽象。

在以前,可能会mixin方式来进行混入,不同页面引入这个mixin,调整一下data里面的api地址即可。但当Mixin变的多的时候,就会存在很多问题,如配置项过于分散,变量,方法难以追踪,不知道是哪个mix进来的,重名的时候没有很好的办法处理等。

Vue3最终都是setup,如果我们使用函数式的方法,把相关的逻辑都封装在一个函数内,最终暴露给需要使用setup即可。在React hooks中的也是这样类似的思想。于是我们有了usePageData

usePageData

这个方法其实类似vue2中的mixin,但是更加灵活,函数式的编程思路也让逻辑上更加统一。我们先添加一些操作数据的方法:

export const usePageData = (fetchApi, form) => {
    // 表格操作方法
    const addItem = () => {};
    const editItem = index => {};
    const deleteItem = index => {};
    // 详情操作方法
    const confirmItem = item => {};
    const cancelEdit = () => {};
    return {
        addItem,
        editItem,
        deleteItem,
        confirmItem,
        cancelEdit
    }
})
复制代码

return出去的值,可以直接给页面模板中使用,对应的修改一下staff.vue中的按钮事件:

<button @click="addItem">新增</button>
<button @click="editItem(index)">编辑</button>
<button @click="deleteItem(index)">删除</button>
...
 <div class="btn-group">
    <button @click="confirmItem(state.form)">确认</button>
    <button @click="cancelEdit">取消</button>
</div>
复制代码

继续回到pagedata.js: 初始化的时候需要获取接口数据,这里不同对应页面的api不同,所以usePageData增加一个fetchApi参数,在mounted中完成,vue3需要引入onMounted方法,添加代码:

import { onMounted } from 'vue';
export const usePageData = (fetchApi, form) => {
   ...
    onMounted(async () => {
        let resList = await fetchApi();
        console.log(resList)
    });
    return {
        ...
    }
复制代码

fetchApi:

回到staff.vue,调用的时候是这样:

import { fetchStaff } from '../api';
const pageData = usePageData(fetchStaff, state.form);
复制代码

先看看fetchStaff,主要是对api的封装,用于请求接口:

api.js中fetchStaff 对底层接口封装,这里使用本地数据来模拟

import request from '../utils/request';

export const fetchStaff = query => {
    return request({
        url: './staff.json',
        method: 'get',
        params: query
    });
};
复制代码

request.js 使用axios作为ajax的简单封装

import axios from 'axios';

const service = axios.create({
    timeout: 5000
});

service.interceptors.request.use(
    config => {
        return config;
    },
    error => {
        console.log(error);
        return Promise.reject();
    }
);

service.interceptors.response.use(
    response => {
        if (response.status === 200) {
            return response.data;
        } else {
            Promise.reject();
        }
    },
    error => {
        console.log(error);
        return Promise.reject();
    }
);

export default service;

复制代码

到现在可以测试console.log(resList),可以验证数据是否正确返回了。

引入Store

在store/index.js中添加代码,vue3中store通过createStore方式创建,完整代码如下:

import { createStore } from "vuex";

export default createStore({
  state: {
    pageData: {}, // 保存当前页面列表数据和总条目
    activeIndex: null //当前激活的项(即正在编辑的)
  },
  getters: {
    // 当前正在编辑的项,根据activeIndex寻找
    activeItem: state => {
      const {list} = state.pageData;
      if(state.activeIndex !== null
        && state.activeIndex !== undefined
        && state.activeIndex > -1
        && list
        && list.length
      ) {
        return list[state.activeIndex];
      }
      return null;
  },
  },
  mutations: {
    // 设置正在编辑的下标
    SET_ACTIVE_ITEM(state, index) {
      state.activeIndex = index;
    },
    // 获取整体页面数据
    GET_PAGE_DATA(state, payload) {
      state.pageData = payload;
    },
    // 详情页中的“确定”操作
    // 需要判断是否存在Item参数,用于区分是编辑还是新增的情况
    CONFIRM_EDIT_ITEM(state, item) {
      const {activeIndex, pageData} = state;
      const {list} = pageData;
      if (!list) {
        return;
      }
      if (activeIndex) {
        Object.assign(list[activeIndex], item);
      } else {
        list.push(Object.assign({}, item));
      }
      // 确定完成后清空activeIndex 
      state.activeIndex = null;
    },
    // 详情页中的“取消”操作,清空当前正在编辑的下标
    CLEAR_ACTIVE_ITEM(state) {
      state.activeIndex = null;
    },
    // 删除数据中的一项
    DELETE_ITEM(state, index) {
      const {list} = state.pageData;
      list.splice(index, 1);
    }
  },
  actions: {
    GET_PAGE_DATA({ commit }, payload) {
      commit('GET_PAGE_DATA', payload)
    },
    SET_ACTIVE_ITEM({ commit }, index) {
      commit('SET_ACTIVE_ITEM', index)
    },
    CONFIRM_EDIT_ITEM({ commit }, item) {
      commit('CONFIRM_EDIT_ITEM', item)
    },
    CLEAR_ACTIVE_ITEM({ commit }) {
      commit('CLEAR_ACTIVE_ITEM')
    },
    DELETE_ITEM({commit}, index) {
      commit('DELETE_ITEM', index)
    }
  }
});
复制代码

在pageData中引入store

对按钮操作进行修改,pageData完整代码如下:

import { onMounted, computed, watch, ref } from 'vue';
// 通过useStore引入
import { useStore } from 'vuex';
export const usePageData = (fetchApi, form) => {
  // 对form进行拷贝一份原始值,用于保存后对数据的清空。
  const initForm = Object.assign({}, form);
  const store = useStore();
  
  // 这里store中的页面数据,用于显示
  const pageData = computed(() => store.state.pageData);
  const activeIndex = computed(() => store.state.activeIndex);
  
  // activeItem即存在编辑中的变量,这里需要把form内的值更新成当前激活的数据
  const activeItem = computed(() => store.getters.activeItem);
  watch(activeItem, () => {
    if (!activeItem.value) return;
    for (let key in form) {
      form[key] = activeItem.value[key];
    }
  });
  
  let isShowDetail = ref(false);
  const editItem = index => {
    isShowDetail.value = true;
    store.dispatch('SET_ACTIVE_ITEM', index);
  };
  const addItem = () => {
    isShowDetail.value = true;
    store.dispatch('SET_ACTIVE_ITEM', null);
  };
  const deleteItem = index => {
    store.dispatch('DELETE_ITEM', index);
  };
  const confirmItem = item => {
    isShowDetail.value = false;
    store.dispatch('CONFIRM_EDIT_ITEM', item);
    Object.assign(form, initForm);
  };
  const cancelEdit = () => {
    isShowDetail.value = false;
    store.dispatch('CLEAR_ACTIVE_ITEM');
    Object.assign(form, initForm);
  };
  onMounted(async () => {
    let resList = await fetchApi();
    store.dispatch('GET_PAGE_DATA', resList);
  });

  return {
    isShowDetail,
    pageData,
    activeIndex,
    editItem,
    addItem,
    confirmItem,
    cancelEdit,
    deleteItem
  }
}
复制代码

相关的数据和变量都处理好了,更新一下页面模板:

<template>
  <div class="home">
    <button @click="addItem">新增</button>
    <table>
      <thead>
        <th>姓名</th>
        <th>部门</th>
        <th>职位</th>
        <th>入职日期</th>
        <th>操作</th>
      </thead>
      <tbody>
        <tr v-for="(staff, index) in pageData.list" :key="staff.id">
          <td>{{staff.name}}</td>
          <td>{{staff.department}}</td>
          <td>{{staff.position}}</td>
          <td>{{staff.date}}</td>
          <td>
            <button @click="editItem(index)">编辑</button>
            <button @click="deleteItem(index)">删除</button>
          </td>
        </tr>
      </tbody>
    </table>
    <div>总数:{{pageData.total}}</div>
    <div class="dialog-detail" v-show="isShowDetail">
      <div class="form-item">
        <span class="label">姓名:</span>
        <input type="text" v-model="state.form.name">
      </div>
      <div class="form-item">
        <span class="label">部门:</span>
        <input type="text" v-model="state.form.department">
      </div>
      <div class="form-item">
        <span class="label">职位:</span>
         <input type="text" v-model="state.form.position">
      </div>
      <div class="form-item">
        <span class="label">入职日期:</span>
         <input type="text" v-model="state.form.date">
      </div>
      <div class="btn-group">
        <button @click="confirmItem(state.form)">确认</button>
        <button @click="cancelEdit">取消</button>
      </div>
    </div>
    
  </div>
</template>

复制代码

效果

最后看一下结果,页面初始化加载默认数据:

新增:

编辑:

删除:

总结

通过composition-api方式来组织代码,带来了新的编程体验,但是也对编程者的要求变得更高,如何在可维护,可复用,可理解中找到平衡点,也是对我们的一个挑战。

本文案例完整代码见: github.com/ccxryan/vue…

一些参考链接: