技術棧

  • vite2
  • vue 3.0.5
  • vue-router 4.0.6
  • vue-data-state 0.1.1
  • element-plus 1.0.2-beta.39

前情回顧

前面介紹的表單控制元件和查詢控制元件,都是原子性的,實現自己的功能即可。

而這裡要介紹的是管理後臺裡面的各個元件之間的狀態關係。

為啥需要狀態?因為元件劃分的非常原子化(細膩),所以造成了很多的元件,那麼元件之間就需要一種“通訊方式”,這個就是狀態了。不僅僅是傳遞資料,還可以實現事件匯流排。

頁面結構

一般的後臺管理大體是這樣的結構:

具體專案裡頁面結構會有一些變化,但是總體結構不會有太大的改變。

做出來的效果大體是這樣的:

  • 動態選單

    根據使用者許可權載入需要的選單。

  • 動態 tab

    點選一下左面的選單,建立一個新的tab,然後載入對應的元件,一般是列表頁面(元件),也可以是其他頁面(元件)。

  • 查詢

    各種查詢條件那是必備的,總不能沒有查詢功能吧,查詢控制元件需要提供查詢條件。

  • 操作按鈕組

    裡面可以有常見的新增、修改、刪除、檢視按鈕,也可以有自定義的其他按鈕。可以“彈窗”也可以直接呼叫後端API。

  • 列表

    顯示客戶需要的資料,看起來簡單,但是要和查詢、翻頁、新增、修改、刪除等功能配合。

  • 分頁

    這是和列表最接近的一個需求,因為資料有可能很大,不能一次性都顯示出來,那麼就需要分頁處理,所以分頁控制元件和列表控制元件就是天然CP。

  • 表單(新增、修改)

    資料提交之後,為了便於確認資料新增成功,是不是需要通知列表去更新資料呢?總不能填完資料,列表一點變化都沒有吧。

  • 刪除

    資料刪掉了,不管是物理刪除還是邏輯刪除,列表裡面都不需要再顯示出來了。

    也就是說刪除後要通知列表更新資料。

總之,各個元件直接需要統籌一下狀態關係。

視訊演示

我們來看一下實際效果。

【放視訊】

設計狀態

我們整理一下需求,用腦圖表達出來:

使用“輕量級狀態管理”定義狀態:

/store-ds/index.js

import VuexDataState from 'vue-data-state'

export default VuexDataState.createStore({
global: { // 全域性狀態
userOnline: {
name: 'jyk' //
}
},
local: { // 區域性狀態
dataListState () { // 獲取列表資料的狀態 dataPagerState
return {
query: {}, // 查詢條件
pager: { // 分頁引數
pageTotal: 100, // 0:需要統計總數;其他:不需要統計總數
pageSize: 5, // 一頁記錄數
pageIndex: 1, // 第幾頁的資料,從 1 開始
orderBy: { id: false } // 排序欄位
},
choice: { // 列表裡面選擇的記錄
dataId: '', // 單選,便於修改和刪除
dataIds: [], // 多選,便於批量刪除
row: {}, // 選擇的記錄資料,僅限於列表裡面的。
rows: [] // 選擇的記錄資料,僅限於列表裡面的。
},
hotkey: () => {}, // 處理快捷鍵的事件,用於操作按鈕
reloadFirstPager: () => {}, // 重新載入第一頁,統計總數(新增後)
reloadCurrentPager: () => {}, // 重新載入當前頁,不統計總數(修改後)
reloadPager: () => {} // 重新載入當前頁,統計總數(刪除後)
}
}
},
init (state) {
}
})

這裡沒有使用 Vuex,因為我覺得 Vuex 有點臃腫,還是自己做的清爽。

另外,狀態裡面除了資料之外,還可以有方法(事件匯流排)。

元件裡面使用輕量級狀態的方法

// 引入狀態
import VueDS from 'vue-data-state' // 訪問狀態
const { reg, get } = VueDS.useStore()
// 父元件註冊列表的狀態
const state = reg.dataListState() // 子元件裡面獲取父元件註冊的狀態
const dataListState = get.dataListState()

先引入狀態,然後在父元件註冊(也就是注入)狀態,然後在子元件就可以獲取狀態。

函式名就是 /store-ds/index.js 裡面定義的名稱。

然後我們還可以仿照 MVC 的 Controllar ,做一個控制類,當然也可以叫做管理類。

叫什麼不是重點,重點是實現了什麼功能。

列表的管理類

我們可以為列表的狀態寫一個狀態的管理類。

這個類是在單獨的 js 檔案裡面,並不需要像 Vuex 那樣去設定 action 或者 module。

/control/data-list.js

import { watch, reactive } from 'vue'
// 狀態
import VueDS from 'vue-data-state' // 仿後端API
import service from '../api/dataList-service.js' /**
* * 資料列表的通用管理類
* * 註冊列表的狀態
* * 關聯獲取資料的方式
* * 設定快捷鍵
* @param {string} modeluId 模組ID
* @returns 列表狀態管理類
*/
export default function dataListControl (modeluId) {
// 顯示資料列表的陣列
const dataList = reactive([])
// 模擬後端API
const { loadDataList } = service() // 訪問狀態
const { reg, get } = VueDS.useStore()
// 子元件裡面獲取父元件註冊的狀態
const dataListState = get.dataListState() // 資料載入中
let isLoading = false /**
* 父元件註冊狀態
* @returns 註冊列表狀態
*/
const regDataListState = () => {
// 註冊列表的狀態,用於分頁、查詢、新增、修改、刪除等
const state = reg.dataListState() // 重新載入第一頁,統計總數(新增、查詢後)
state.reloadFirstPager = () => {
isLoading = true
state.pager.pageIndex = 1 // 顯示第一頁 // 獲取資料
loadDataList(modeluId, state.pager, state.query, true).then((data) => {
state.pager.pageTotal = data.count
dataList.length = 0
dataList.push(...data.list)
isLoading = false
})
}
// 先執行一下,獲取初始資料
state.reloadFirstPager() // 重新載入當前頁,不統計總數(修改後)
state.reloadCurrentPager = () => {
// 獲取資料
loadDataList(modeluId, state.pager, state.query).then((data) => {
dataList.length = 0
dataList.push(...data)
})
} // 重新載入當前頁,統計總數(刪除後)
state.reloadPager = () => {
// 獲取資料
loadDataList(modeluId, state.pager, state.query, true).then((data) => {
state.pager.pageTotal = data.count
dataList.length = 0
dataList.push(...data.list)
})
} // 監聽,用於翻頁控制元件的翻頁。翻頁,獲取指定頁號的資料
watch(() => state.pager.pageIndex, () => {
// 避免重複載入
if (isLoading) {
// 不獲取資料
return
}
// 獲取資料
loadDataList(modeluId, state.pager, state.query).then((data) => {
dataList.length = 0
dataList.push(...data)
})
}) return state
} return {
setHotkey, // 設定快捷鍵,(後面介紹)
regDataListState, // 父元件註冊狀態
dataList, // 父元件獲得列表
dataListState // 子元件獲得狀態
}
}

管理類的功能:

  1. 父元件註冊狀態
  2. 子元件獲取狀態
  3. 定義列表資料的容器
  4. 各種監聽
  5. 事件匯流排

父元件註冊狀態

因為使用的是區域性的狀態,並不是全域性狀態,所以在需要使用的時候,首先需要在父元件裡面註冊一下。看起來似乎沒有全域性狀態簡單,但是可以更好的實現複用,更輕鬆的區分資料,兄弟元件的狀態不會混淆。

子元件獲取狀態

因為或者狀態必須在vue的直接函式內才行,所以才需要先把狀態獲取出來,而不能等到觸發事件了再獲取。

定義列表資料的容器

列表資料並沒有在狀態裡面定義,而是在管理類裡面定義的,因為主要列表元件才需要這個列表資料,其他的元件並不關心列表資料。

監聽:

  • 監聽頁號的變化,依據當前的查詢條件獲取新的記錄,用於翻頁,不用重新統計總數。

事件:

  • 統計總數並且翻到第一頁,用於查詢條件變化,新增新記錄。
  • 重新獲取當前頁號的列表資料,用於修改資料後的更新。
  • 重新獲取當前頁號的列表資料,並且統計總記錄數,用於刪除資料後的更新。

是否重新統計總數

可能你會發現上面獲取資料裡面有一個明顯的區別,那就是是否需要統計總數。

在資料量非常大的情況下,如果每次翻頁都重新統計總數,那麼會嚴重影響效能!

其實仔細考慮一下,一些情況是不用重新統計總數的,比如翻頁、修改後的更新等,這些操作都不會影響總記錄數(不考慮併發操作),那麼我們也就不必每次都重新統計。

檔案結構

基礎功能搭建好了之後,剩下的就簡單了,建立元件設定模板、控制元件、元件和使用狀態即可。

總體結構如下:

列表狀態的使用

基礎工作做好之後我們來看看,在各個元件裡面是如何使用狀態的。

查詢

首先看看查詢,使用者設定查詢條件後,查詢控制元件把查詢條件記入狀態裡面。

然後呼叫狀態管理裡的 reloadFirstPager ,獲取列表資料。

查詢控制元件支援防抖功能。

<template>
<!--查詢-->
<nf-el-find
v-model="listState.query"
v-bind="findProps"
@my-change="myChange"
/>
</template>

直接使用查詢控制元件,模板內容是不是很簡單了?

import { reactive } from 'vue'
// 載入json
import loadJson from './control/loadjson.js'
// 狀態
import VueDS from 'vue-data-state' // 元件
import nfElFind from '/ctrl/nf-el-find/el-find-div.vue' // 屬性:模組ID、查詢條件
const props = defineProps({
moduleId: [Number, String]
}) // 設定 查詢的 meta
const findProps = reactive({reload: true})
loadJson(props.moduleId, 'find', findProps) // 訪問狀態
const { get } = VueDS.useStore()
// 獲取狀態
const listState = get.dataListState()
// 使用者設定查詢條件後觸發
const myChange = (query) => {
// 獲取第一頁的資料,並且重新統計總數
listState.reloadFirstPager()
}

分頁

分頁就很簡單了,查詢條件由查詢控制元件搞定,所以這裡只需要按照 el-pagination 的要求,把分頁狀態設定給 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>

直接把狀態作為屬性值。

// 狀態
import VueDS from 'vue-data-state' // 訪問狀態
const { get } = VueDS.useStore()
// 獲取分頁資訊
const pager = get.dataListState().pager

直接獲取分頁狀態設定 el-pagination 的屬性即可。

翻頁的時候 el-pagination 會自動修改 pager.pageIndex 的值,而狀態管理裡面會監聽其變化,然後獲取對應的列表資料。

新增、修改

新增完成之後,總記錄數會增加,所以需要重新統計總記錄數,然後翻到第一頁。

而修改之後,一般總記錄數並不會變化,所以只需要重新獲取當前頁號的資料即可。

<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' // 狀態
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' // 訪問狀態
const { get } = VueDS.useStore() // 定義屬性
const props = defineProps({
moduleId: [Number, String], // 模組ID
formMetaId: [Number, String], // 表單的ID
dataId: Number, // 修改或者顯示的記錄的ID
type: String // 型別:新增、修改、檢視
}) // 模組ID + 表單ID = 自己的標誌
const modFormId = computed(() => props.moduleId + props.formMetaId) // 子元件裡面獲取狀態
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) // 監聽記錄ID的變化,載入資料便於修改
watch(() => props.dataId, (id) => {
if (props.type !== 'add') {
// 載入資料
getData( id ).then((data) => {
Object.assign(model, data[0])
formProps.reload = !formProps.reload
})
}
},
{immediate: true}) // 提交資料
const mysubmit = () => {
// 判斷是新增還是修改
if (props.type === 'add'){
// 新增資料
addData(model).then(() => {
ElMessage({
type: 'success',
message: '新增資料成功!'
})
// 重新載入第一頁的資料
dataListState.reloadFirstPager()
})
} else if (props.type === 'update') {
// 修改資料
updateData(model, props.dataId).then(() => {
ElMessage({
type: 'success',
message: '修改資料成功!'
})
// 重新載入當前頁號的資料
dataListState.reloadCurrentPager()
})
}
}

程式碼稍微多了一些,基本上就是在合適的時機呼叫狀態裡的重新載入資料的事件。

刪除

刪除之後也會影響總記錄數,所以需要重新統計,然後重新整理當前頁號的列表資料。

刪除的程式碼寫在了操作按鈕的元件裡面,對應刪除按鈕觸發的事件:

      case 'delete':
dialogInfo.show = false
// 刪除
ElMessageBox.confirm('此操作將刪除該記錄, 是否繼續?', '溫馨提示', {
confirmButtonText: '刪除',
cancelButtonText: '後悔了',
type: 'warning'
}).then(() => {
// 後端API
const { deleteData } = service(props.moduleId + meta.formMetaId)
deleteData(dataListState.choice.dataId).then(() => {
ElMessage({
type: 'success',
message: '刪除成功!'
})
dataListState.reloadPager() // 重新整理列表資料
})
}).catch(() => {
ElMessage({
type: 'info',
message: '已經取消了。'
})
})
break

刪除成功之後,呼叫狀態的 dataListState.reloadPager() 重新整理列表頁面。

快捷鍵

我是喜歡用快捷鍵實現一些操作的,比如翻頁、新增等操作。

用滑鼠去找到“上一頁”、“下一頁”或者需要的頁號,這個太麻煩。

如果通過鍵盤操作就能翻頁,是不是可以更方便一些呢?

比如 w、a、s、d,分別表示上一頁、下一頁、首頁、末頁;數字鍵就是要翻到的頁號。

是不是有一種打遊戲的感覺?

實現方式也比較簡單,一開始打算用 Vue 的鍵盤事件,但是發現似乎不太好用,於是改用監聽document 的鍵盤事件。


/**
* 列表頁面的快捷鍵
*/
const setHotkey = (dataListState) => {
// 設定分頁、操作按鈕等快捷鍵
// 計時器做一個防抖
let timeout
let tmpIndex = 0 // 頁號
document.onkeydown = (e) => {
if (!(e.target instanceof HTMLBodyElement)) return // 表單觸發,退出
if (e.altKey) {
// alt + 的快捷鍵,呼叫操作按鈕的事件
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:
// 判斷是不是數字
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()
}
}

這段程式碼,其實是放在狀態管理類裡面的,拿出來單獨介紹一下,避免混淆。

  • document.onkeydown

    監聽鍵盤按下的事件,這個 e 並不是原生的 e,而是Vue封裝之後的 e。

    首先要判斷一下事件來源,如果是 input 等觸發的需要跳過,以免影響正常的資料輸入。

    然後是判斷按了哪個按鍵,根據需求呼叫對應的函式。

  • altKey

    是否按下了 alt 鍵。有些快捷鍵可以是組合方式,本來想用 ctrl 鍵的,但是發現在網頁裡面 ctrl 開頭的快捷鍵實在太多,搶不過,所以只好 用 alt。

  • alt + a 相當於按 新增按鈕

  • alt + s 相當於按 修改按鈕

  • alt + d 相當於按 刪除按鈕

你覺得 a 代表 add,d 代表 delete嗎?

其實不是的,a、s、d 的鍵位可以對應操作按鈕裡面前三個按鈕。就醬。

  • 數字翻頁的防抖

    如果不做防抖的話,只能實現 1-9 的頁號翻頁,如果做了防抖的話,基本可以做到三位數頁號的翻頁。所以手欠做了個防抖。

開源

https://gitee.com/naturefw/nf-vite2-element

線上演示

https://naturefw.gitee.io/nf-vue-cdn/elecontrol/

nf-vite2-element 的倉庫沒來得及開通pager服務,所以放在另一個倉庫裡面了。