技術(shù)棧
- vite2
- vue 3.0.5
- vue-router 4.0.6
- vue-data-state 0.1.1
- element-plus 1.0.2-beta.39
前情回顧
前面介紹的表單控件和查詢控件,都是原子性的,實現(xiàn)自己的功能即可。
而這里要介紹的是管理后臺里面的各個組件之間的狀態(tài)關(guān)系。
為啥需要狀態(tài)?因為組件劃分的非常原子化(細(xì)膩),所以造成了很多的組件,那么組件之間就需要一種“通訊方式”,這個就是狀態(tài)了。不僅僅是傳遞數(shù)據(jù),還可以實現(xiàn)事件總線。
頁面結(jié)構(gòu)
一般的后臺管理大體是這樣的結(jié)構(gòu):

具體項目里頁面結(jié)構(gòu)會有一些變化,但是總體結(jié)構(gòu)不會有太大的改變。
做出來的效果大體是這樣的:

動態(tài)菜單
根據(jù)用戶權(quán)限加載需要的菜單。動態(tài) tab
點擊一下左面的菜單,創(chuàng)建一個新的tab,然后加載對應(yīng)的組件,一般是列表頁面(組件),也可以是其他頁面(組件)。查詢
各種查詢條件那是必備的,總不能沒有查詢功能吧,查詢控件需要提供查詢條件。操作按鈕組
里面可以有常見的添加、修改、刪除、查看按鈕,也可以有自定義的其他按鈕??梢浴皬棿啊币部梢灾苯诱{(diào)用后端API。列表
顯示客戶需要的數(shù)據(jù),看起來簡單,但是要和查詢、翻頁、添加、修改、刪除等功能配合。分頁
這是和列表最接近的一個需求,因為數(shù)據(jù)有可能很大,不能一次性都顯示出來,那么就需要分頁處理,所以分頁控件和列表控件就是天然CP。表單(添加、修改)
數(shù)據(jù)提交之后,為了便于確認(rèn)數(shù)據(jù)添加成功,是不是需要通知列表去更新數(shù)據(jù)呢?總不能填完數(shù)據(jù),列表一點變化都沒有吧。刪除
數(shù)據(jù)刪掉了,不管是物理刪除還是邏輯刪除,列表里面都不需要再顯示出來了。
也就是說刪除后要通知列表更新數(shù)據(jù)。
總之,各個組件直接需要統(tǒng)籌一下狀態(tài)關(guān)系。
設(shè)計狀態(tài)
我們整理一下需求,用腦圖表達(dá)出來:

使用“輕量級狀態(tài)管理”定義狀態(tài):
/store-ds/index.js
import VuexDataState from 'vue-data-state'
export default VuexDataState.createStore({
global: { // 全局狀態(tài)
userOnline: {
name: 'jyk' //
}
},
local: { // 局部狀態(tài)
dataListState () { // 獲取列表數(shù)據(jù)的狀態(tài) dataPagerState
return {
query: {}, // 查詢條件
pager: { // 分頁參數(shù)
pageTotal: 100, // 0:需要統(tǒng)計總數(shù);其他:不需要統(tǒng)計總數(shù)
pageSize: 5, // 一頁記錄數(shù)
pageIndex: 1, // 第幾頁的數(shù)據(jù),從 1 開始
orderBy: { id: false } // 排序字段
},
choice: { // 列表里面選擇的記錄
dataId: '', // 單選,便于修改和刪除
dataIds: [], // 多選,便于批量刪除
row: {}, // 選擇的記錄數(shù)據(jù),僅限于列表里面的。
rows: [] // 選擇的記錄數(shù)據(jù),僅限于列表里面的。
},
hotkey: () => {}, // 處理快捷鍵的事件,用于操作按鈕
reloadFirstPager: () => {}, // 重新加載第一頁,統(tǒng)計總數(shù)(添加后)
reloadCurrentPager: () => {}, // 重新加載當(dāng)前頁,不統(tǒng)計總數(shù)(修改后)
reloadPager: () => {} // 重新加載當(dāng)前頁,統(tǒng)計總數(shù)(刪除后)
}
}
},
init (state) {
}
})
這里沒有使用 Vuex,因為我覺得 Vuex 有點臃腫,還是自己做的清爽。
另外,狀態(tài)里面除了數(shù)據(jù)之外,還可以有方法(事件總線)。
組件里面使用輕量級狀態(tài)的方法
// 引入狀態(tài)
import VueDS from 'vue-data-state'
// 訪問狀態(tài)
const { reg, get } = VueDS.useStore()
// 父組件注冊列表的狀態(tài)
const state = reg.dataListState()
// 子組件里面獲取父組件注冊的狀態(tài)
const dataListState = get.dataListState()
先引入狀態(tài),然后在父組件注冊(也就是注入)狀態(tài),然后在子組件就可以獲取狀態(tài)。
函數(shù)名就是 /store-ds/index.js 里面定義的名稱。
然后我們還可以仿照 MVC 的 Controllar ,做一個控制類,當(dāng)然也可以叫做管理類。
叫什么不是重點,重點是實現(xiàn)了什么功能。
列表的管理類
我們可以為列表的狀態(tài)寫一個狀態(tài)的管理類。
這個類是在單獨(dú)的 js 文件里面,并不需要像 Vuex 那樣去設(shè)置 action 或者 module。
/control/data-list.js
import { watch, reactive } from 'vue'
// 狀態(tài)
import VueDS from 'vue-data-state'
// 仿后端API
import service from '../api/dataList-service.js'
/**
* * 數(shù)據(jù)列表的通用管理類
* * 注冊列表的狀態(tài)
* * 關(guān)聯(lián)獲取數(shù)據(jù)的方式
* * 設(shè)置快捷鍵
* @param {string} modeluId 模塊ID
* @returns 列表狀態(tài)管理類
*/
export default function dataListControl (modeluId) {
// 顯示數(shù)據(jù)列表的數(shù)組
const dataList = reactive([])
// 模擬后端API
const { loadDataList } = service()
// 訪問狀態(tài)
const { reg, get } = VueDS.useStore()
// 子組件里面獲取父組件注冊的狀態(tài)
const dataListState = get.dataListState()
// 數(shù)據(jù)加載中
let isLoading = false
/**
* 父組件注冊狀態(tài)
* @returns 注冊列表狀態(tài)
*/
const regDataListState = () => {
// 注冊列表的狀態(tài),用于分頁、查詢、添加、修改、刪除等
const state = reg.dataListState()
// 重新加載第一頁,統(tǒng)計總數(shù)(添加、查詢后)
state.reloadFirstPager = () => {
isLoading = true
state.pager.pageIndex = 1 // 顯示第一頁
// 獲取數(shù)據(jù)
loadDataList(modeluId, state.pager, state.query, true).then((data) => {
state.pager.pageTotal = data.count
dataList.length = 0
dataList.push(...data.list)
isLoading = false
})
}
// 先執(zhí)行一下,獲取初始數(shù)據(jù)
state.reloadFirstPager()
// 重新加載當(dāng)前頁,不統(tǒng)計總數(shù)(修改后)
state.reloadCurrentPager = () => {
// 獲取數(shù)據(jù)
loadDataList(modeluId, state.pager, state.query).then((data) => {
dataList.length = 0
dataList.push(...data)
})
}
// 重新加載當(dāng)前頁,統(tǒng)計總數(shù)(刪除后)
state.reloadPager = () => {
// 獲取數(shù)據(jù)
loadDataList(modeluId, state.pager, state.query, true).then((data) => {
state.pager.pageTotal = data.count
dataList.length = 0
dataList.push(...data.list)
})
}
// 監(jiān)聽,用于翻頁控件的翻頁。翻頁,獲取指定頁號的數(shù)據(jù)
watch(() => state.pager.pageIndex, () => {
// 避免重復(fù)加載
if (isLoading) {
// 不獲取數(shù)據(jù)
return
}
// 獲取數(shù)據(jù)
loadDataList(modeluId, state.pager, state.query).then((data) => {
dataList.length = 0
dataList.push(...data)
})
})
return state
}
return {
setHotkey, // 設(shè)置快捷鍵,(后面介紹)
regDataListState, // 父組件注冊狀態(tài)
dataList, // 父組件獲得列表
dataListState // 子組件獲得狀態(tài)
}
}
管理類的功能:
- 父組件注冊狀態(tài)
- 子組件獲取狀態(tài)
- 定義列表數(shù)據(jù)的容器
- 各種監(jiān)聽
- 事件總線
父組件注冊狀態(tài)
因為使用的是局部的狀態(tài),并不是全局狀態(tài),所以在需要使用的時候,首先需要在父組件里面注冊一下??雌饋硭坪鯖]有全局狀態(tài)簡單,但是可以更好的實現(xiàn)復(fù)用,更輕松的區(qū)分?jǐn)?shù)據(jù),兄弟組件的狀態(tài)不會混淆。
子組件獲取狀態(tài)
因為或者狀態(tài)必須在vue的直接函數(shù)內(nèi)才行,所以才需要先把狀態(tài)獲取出來,而不能等到觸發(fā)事件了再獲取。
定義列表數(shù)據(jù)的容器
列表數(shù)據(jù)并沒有在狀態(tài)里面定義,而是在管理類里面定義的,因為主要列表組件才需要這個列表數(shù)據(jù),其他的組件并不關(guān)心列表數(shù)據(jù)。
監(jiān)聽:
- 監(jiān)聽頁號的變化,依據(jù)當(dāng)前的查詢條件獲取新的記錄,用于翻頁,不用重新統(tǒng)計總數(shù)。
事件:
- 統(tǒng)計總數(shù)并且翻到第一頁,用于查詢條件變化,添加新記錄。
- 重新獲取當(dāng)前頁號的列表數(shù)據(jù),用于修改數(shù)據(jù)后的更新。
- 重新獲取當(dāng)前頁號的列表數(shù)據(jù),并且統(tǒng)計總記錄數(shù),用于刪除數(shù)據(jù)后的更新。
是否重新統(tǒng)計總數(shù)
可能你會發(fā)現(xiàn)上面獲取數(shù)據(jù)里面有一個明顯的區(qū)別,那就是是否需要統(tǒng)計總數(shù)。
在數(shù)據(jù)量非常大的情況下,如果每次翻頁都重新統(tǒng)計總數(shù),那么會嚴(yán)重影響性能!
其實仔細(xì)考慮一下,一些情況是不用重新統(tǒng)計總數(shù)的,比如翻頁、修改后的更新等,這些操作都不會影響總記錄數(shù)(不考慮并發(fā)操作),那么我們也就不必每次都重新統(tǒng)計。
文件結(jié)構(gòu)
基礎(chǔ)功能搭建好了之后,剩下的就簡單了,建立組件設(shè)置模板、控件、組件和使用狀態(tài)即可。
總體結(jié)構(gòu)如下:

列表狀態(tài)的使用
基礎(chǔ)工作做好之后我們來看看,在各個組件里面是如何使用狀態(tài)的。
查詢
首先看看查詢,用戶設(shè)置查詢條件后,查詢控件把查詢條件記入狀態(tài)里面。
然后調(diào)用狀態(tài)管理里的 reloadFirstPager ,獲取列表數(shù)據(jù)。
查詢控件支持防抖功能。
<template>
<!--查詢-->
<nf-el-find
v-model="listState.query"
v-bind="findProps"
@my-change="myChange"
/>
</template>
直接使用查詢控件,模板內(nèi)容是不是很簡單了?
import { reactive } from 'vue'
// 加載json
import loadJson from './control/loadjson.js'
// 狀態(tài)
import VueDS from 'vue-data-state'
// 組件
import nfElFind from '/ctrl/nf-el-find/el-find-div.vue'
// 屬性:模塊ID、查詢條件
const props = defineProps({
moduleId: [Number, String]
})
// 設(shè)置 查詢的 meta
const findProps = reactive({reload: true})
loadJson(props.moduleId, 'find', findProps)
// 訪問狀態(tài)
const { get } = VueDS.useStore()
// 獲取狀態(tài)
const listState = get.dataListState()
// 用戶設(shè)置查詢條件后觸發(fā)
const myChange = (query) => {
// 獲取第一頁的數(shù)據(jù),并且重新統(tǒng)計總數(shù)
listState.reloadFirstPager()
}
分頁
分頁就很簡單了,查詢條件由查詢控件搞定,所以這里只需要按照 el-pagination 的要求,把分頁狀態(tài)設(shè)置給 el-pagination 的屬性即可。
<template>
<!--分頁-->
<el-pagination
background
layout="prev, pager, next"
v-model:currentPage="pager.pageIndex"
:page-size="pager.pageSize"
:total="pager.pageTotal">
</el-pagination>
</template>
直接把狀態(tài)作為屬性值。
// 狀態(tài)
import VueDS from 'vue-data-state'
// 訪問狀態(tài)
const { get } = VueDS.useStore()
// 獲取分頁信息
const pager = get.dataListState().pager
直接獲取分頁狀態(tài)設(shè)置 el-pagination 的屬性即可。
翻頁的時候 el-pagination 會自動修改 pager.pageIndex 的值,而狀態(tài)管理里面會監(jiān)聽其變化,然后獲取對應(yīng)的列表數(shù)據(jù)。
添加、修改
添加完成之后,總記錄數(shù)會增加,所以需要重新統(tǒng)計總記錄數(shù),然后翻到第一頁。
而修改之后,一般總記錄數(shù)并不會變化,所以只需要重新獲取當(dāng)前頁號的數(shù)據(jù)即可。
<template>
<div>
<!--表單-->
<el-form
ref="formControl"
v-model="model"
:partModel="partModel"
v-bind="formProps"
>
</el-form>
<span class="dialog-footer">
<el-button @click="">取 消</el-button>
<el-button type="primary" @click="mysubmit">確 定</el-button>
</span>
</div>
</template>
使用表單控件和兩個按鈕。
import { computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
// 加載json
import loadJson from './control/loadjson.js'
// 狀態(tài)
import VueDS from 'vue-data-state'
// 仿后端API
import service from './api/data-service.js'
// 表單組件
import elForm from '/ctrl/nf-el-form/el-form-div.vue'
// 訪問狀態(tài)
const { get } = VueDS.useStore()
// 定義屬性
const props = defineProps({
moduleId: [Number, String], // 模塊ID
formMetaId: [Number, String], // 表單的ID
dataId: Number, // 修改或者顯示的記錄的ID
type: String // 類型:添加、修改、查看
})
// 模塊ID + 表單ID = 自己的標(biāo)志
const modFormId = computed(() => props.moduleId + props.formMetaId)
// 子組件里面獲取狀態(tài)
const dataListState = get.dataListState(modFormId.value)
// 表單控件的 model
const model = reactive({})
// 表單控件需要的屬性
const formProps = reactive({reload:false})
// 加載需要的 json
loadJson(props.moduleId, 'form_' + props.formMetaId, formProps)
// 仿后端API
const { getData, addData, updateData } = service(modFormId.value)
// 監(jiān)聽記錄ID的變化,加載數(shù)據(jù)便于修改
watch(() => props.dataId, (id) => {
if (props.type !== 'add') {
// 加載數(shù)據(jù)
getData( id ).then((data) => {
Object.assign(model, data[0])
formProps.reload = !formProps.reload
})
}
},
{immediate: true})
// 提交數(shù)據(jù)
const mysubmit = () => {
// 判斷是添加還是修改
if (props.type === 'add'){
// 添加數(shù)據(jù)
addData(model).then(() => {
ElMessage({
type: 'success',
message: '添加數(shù)據(jù)成功!'
})
// 重新加載第一頁的數(shù)據(jù)
dataListState.reloadFirstPager()
})
} else if (props.type === 'update') {
// 修改數(shù)據(jù)
updateData(model, props.dataId).then(() => {
ElMessage({
type: 'success',
message: '修改數(shù)據(jù)成功!'
})
// 重新加載當(dāng)前頁號的數(shù)據(jù)
dataListState.reloadCurrentPager()
})
}
}
代碼稍微多了一些,基本上就是在合適的時機(jī)調(diào)用狀態(tài)里的重新加載數(shù)據(jù)的事件。
刪除
刪除之后也會影響總記錄數(shù),所以需要重新統(tǒng)計,然后刷新當(dāng)前頁號的列表數(shù)據(jù)。
刪除的代碼寫在了操作按鈕的組件里面,對應(yīng)刪除按鈕觸發(fā)的事件:
case 'delete':
dialogInfo.show = false
// 刪除
ElMessageBox.confirm('此操作將刪除該記錄, 是否繼續(xù)?', '溫馨提示', {
confirmButtonText: '刪除',
cancelButtonText: '后悔了',
type: 'warning'
}).then(() => {
// 后端API
const { deleteData } = service(props.moduleId + meta.formMetaId)
deleteData(dataListState.choice.dataId).then(() => {
ElMessage({
type: 'success',
message: '刪除成功!'
})
dataListState.reloadPager() // 刷新列表數(shù)據(jù)
})
}).catch(() => {
ElMessage({
type: 'info',
message: '已經(jīng)取消了。'
})
})
break
刪除成功之后,調(diào)用狀態(tài)的 dataListState.reloadPager() 刷新列表頁面。
快捷鍵
我是喜歡用快捷鍵實現(xiàn)一些操作的,比如翻頁、添加等操作。
用鼠標(biāo)去找到“上一頁”、“下一頁”或者需要的頁號,這個太麻煩。
如果通過鍵盤操作就能翻頁,是不是可以更方便一些呢?
比如 w、a、s、d,分別表示上一頁、下一頁、首頁、末頁;數(shù)字鍵就是要翻到的頁號。
是不是有一種打游戲的感覺?
實現(xiàn)方式也比較簡單,一開始打算用 Vue 的鍵盤事件,但是發(fā)現(xiàn)似乎不太好用,于是改用監(jiān)聽document 的鍵盤事件。
/**
* 列表頁面的快捷鍵
*/
const setHotkey = (dataListState) => {
// 設(shè)置分頁、操作按鈕等快捷鍵
// 計時器做一個防抖
let timeout
let tmpIndex = 0 // 頁號
document.onkeydown = (e) => {
if (!(e.target instanceof HTMLBodyElement)) return // 表單觸發(fā),退出
if (e.altKey) {
// alt + 的快捷鍵,調(diào)用操作按鈕的事件
dataListState.hotkey(e.key)
} else {
// 翻頁
const maxPager = parseInt(dataListState.pager.pageTotal / dataListState.pager.pageSize) + 1
switch (e.key) {
case 'ArrowLeft': // 左箭頭 上一頁
case 'PageUp':
case 'a':
dataListState.pager.pageIndex -= 1
if (dataListState.pager.pageIndex <= 0) {
dataListState.pager.pageIndex = 1
}
break
case 'ArrowRight': // 右箭頭 下一頁
case 'PageDown':
case 'd':
dataListState.pager.pageIndex += 1
if (dataListState.pager.pageIndex >= maxPager) {
dataListState.pager.pageIndex = maxPager
}
break
case 'ArrowUp': // 上箭頭
case 'Home': // 首頁
case 'w':
dataListState.pager.pageIndex = 1
break
case 'ArrowDown': // 下箭頭
case 'End': // 末頁
case 's':
dataListState.pager.pageIndex = maxPager
break
default:
// 判斷是不是數(shù)字
if (!isNaN(parseInt(e.key))) {
// 做一個防抖
tmpIndex = tmpIndex * 10 + parseInt(e.key)
clearTimeout(timeout) // 清掉上一次的計時
timeout = setTimeout(() => {
// 修改 modelValue 屬性
if (tmpIndex === 0) {
dataListState.pager.pageIndex = 10
} else {
if (tmpIndex >= maxPager) {
tmpIndex = maxPager
}
dataListState.pager.pageIndex = tmpIndex
}
tmpIndex = 0
}, 500)
}
}
}
e.stopPropagation()
}
}
這段代碼,其實是放在狀態(tài)管理類里面的,拿出來單獨(dú)介紹一下,避免混淆。
- document.onkeydown
監(jiān)聽鍵盤按下的事件,這個 e 并不是原生的 e,而是Vue封裝之后的 e。
首先要判斷一下事件來源,如果是 input 等觸發(fā)的需要跳過,以免影響正常的數(shù)據(jù)輸入。
然后是判斷按了哪個按鍵,根據(jù)需求調(diào)用對應(yīng)的函數(shù)。

altKey
是否按下了 alt 鍵。有些快捷鍵可以是組合方式,本來想用 ctrl 鍵的,但是發(fā)現(xiàn)在網(wǎng)頁里面 ctrl 開頭的快捷鍵實在太多,搶不過,所以只好 用 alt。alt + a 相當(dāng)于按 添加按鈕
alt + s 相當(dāng)于按 修改按鈕
alt + d 相當(dāng)于按 刪除按鈕
你覺得 a 代表 add,d 代表 delete嗎?
其實不是的,a、s、d 的鍵位可以對應(yīng)操作按鈕里面前三個按鈕。就醬。
- 數(shù)字翻頁的防抖
如果不做防抖的話,只能實現(xiàn) 1-9 的頁號翻頁,如果做了防抖的話,基本可以做到三位數(shù)頁號的翻頁。所以手欠做了個防抖。
開源
https://gitee.com/naturefw/nf-vite2-element
在線演示
https://naturefw.gitee.io/nf-vue-cdn/elecontrol/
nf-vite2-element 的倉庫沒來得及開通pager服務(wù),所以放在另一個倉庫里面了。