vite+Vue3+ts搭建通用后臺(tái)管理系統(tǒng)
通用后臺(tái)管理系統(tǒng)整體架構(gòu)方案(Vue)
項(xiàng)目創(chuàng)建,腳手架的選擇(vite or vue-cli)
vue-cli基于webpack封裝,生態(tài)非常強(qiáng)大,可配置性也非常高,幾乎能夠滿足前端工程化的所有要求。缺點(diǎn)就是配置復(fù)雜,甚至有公司有專門的webpack工程師專門做配置,另外就是webpack由于開發(fā)環(huán)境需要打包編譯,開發(fā)體驗(yàn)實(shí)際上不如vite。vite開發(fā)模式基于esbuild,打包使用的是rollup。急速的冷啟動(dòng)和無縫的hmr在開發(fā)模式下獲得極大的體驗(yàn)提升。缺點(diǎn)就是該腳手架剛起步,生態(tài)上還不及webpack。
本文主要講解使用vite來作為腳手架開發(fā)。(動(dòng)手能力強(qiáng)的小伙伴完全可以使用vite做開發(fā)服務(wù)器,使用webpack做打包編譯放到生產(chǎn)環(huán)境)
為什么選擇vite而不是vue-cli,不論是webpack,parcel,rollup等工具,雖然都極大的提高了前端的開發(fā)體驗(yàn),但是都有一個(gè)問題,就是當(dāng)項(xiàng)目越來越大的時(shí)候,需要處理的js代碼也呈指數(shù)級(jí)增長(zhǎng),打包過程通常需要很長(zhǎng)時(shí)間(甚至是幾分鐘!)才能啟動(dòng)開發(fā)服務(wù)器,體驗(yàn)會(huì)隨著項(xiàng)目越來越大而變得越來越差。
由于現(xiàn)代瀏覽器都已經(jīng)原生支持es模塊,我們只要使用支持esm的瀏覽器開發(fā),那么是不是我們的代碼就不需要打包了?是的,原理就是這么簡(jiǎn)單。vite將源碼模塊的請(qǐng)求會(huì)根據(jù)304 Not Modified進(jìn)行協(xié)商緩存,依賴模塊通過Cache-Control:max-age=31536000,immutable進(jìn)行協(xié)商緩存,因此一旦被緩存它們將不需要再次請(qǐng)求。
軟件巨頭微軟周三(5月19日)表示,從2022年6月15日起,公司某些版本的Windows軟件將不再支持當(dāng)前版本的IE 11桌面應(yīng)用程序。
所以利用瀏覽器的最新特性來開發(fā)項(xiàng)目是趨勢(shì)。
$ npm init @vitejs/app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev
基礎(chǔ)設(shè)置,代碼規(guī)范的支持(eslint+prettier)
vscode 安裝 eslint,prettier,vetur(喜歡用vue3 setup語(yǔ)法糖可以使用volar,這時(shí)要禁用vetur)
打開vscode eslint
eslint
yarn add --dev eslint eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin
prettier
yarn add --dev prettier eslint-config-prettier eslint-plugin-prettier
.prettierrc.js
module.exports = {
printWidth: 180, //一行的字符數(shù),如果超過會(huì)進(jìn)行換行,默認(rèn)為80
tabWidth: 4, //一個(gè)tab代表幾個(gè)空格數(shù),默認(rèn)為80
useTabs: false, //是否使用tab進(jìn)行縮進(jìn),默認(rèn)為false,表示用空格進(jìn)行縮減
singleQuote: true, //字符串是否使用單引號(hào),默認(rèn)為false,使用雙引號(hào)
semi: false, //行位是否使用分號(hào),默認(rèn)為true
trailingComma: 'none', //是否使用尾逗號(hào),有三個(gè)可選值"<none|es5|all>"
bracketSpacing: true, //對(duì)象大括號(hào)直接是否有空格,默認(rèn)為true,效果:{ foo: bar }
jsxSingleQuote: true, // jsx語(yǔ)法中使用單引號(hào)
endOfLine: 'auto'
}
.eslintrc.js
//.eslintrc.js
module.exports = {
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
ecmaFeatures: {
jsx: true
}
},
extends: [
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:prettier/recommended'
]
}
.settings.json(工作區(qū))
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": [
"javascript",
"javascriptreact",
"vue",
"typescript",
"typescriptreact",
"json"
]
}
目錄結(jié)構(gòu)范例
├─.vscode // vscode配置文件
├─public // 無需編譯的靜態(tài)資源目錄
├─src // 代碼源文件目錄
│ ├─apis // apis統(tǒng)一管理
│ │ └─modules // api模塊
│ ├─assets // 靜態(tài)資源
│ │ └─images
│ ├─components // 項(xiàng)目組件目錄
│ │ ├─Form
│ │ ├─Input
│ │ ├─Message
│ │ ├─Search
│ │ ├─Table
│ ├─directives // 指令目錄
│ │ └─print
│ ├─hooks // hooks目錄
│ ├─layouts // 布局組件
│ │ ├─dashboard
│ │ │ ├─content
│ │ │ ├─header
│ │ │ └─sider
│ │ └─fullpage
│ ├─mock // mock apu存放地址,和apis對(duì)應(yīng)
│ │ └─modules
│ ├─router // 路由相關(guān)
│ │ └─helpers
│ ├─store // 狀態(tài)管理相關(guān)
│ ├─styles // 樣式相關(guān)(后面降到css架構(gòu)會(huì)涉及具體的目錄)
│ ├─types // 類型定義相關(guān)
│ ├─utils // 工具類相關(guān)
│ └─views // 頁(yè)面目錄地址
│ ├─normal
│ └─system
└─template // 模板相關(guān)
├─apis
└─page
CSS架構(gòu)之ITCSS + BEM + ACSS
現(xiàn)實(shí)開發(fā)中,我們經(jīng)常忽視CSS的架構(gòu)設(shè)計(jì)。前期對(duì)樣式架構(gòu)的忽略,隨著項(xiàng)目的增大,導(dǎo)致出現(xiàn)樣式污染,覆蓋,難以追溯,代碼重復(fù)等各種問題。因此,CSS架構(gòu)設(shè)計(jì)同樣需要重視起來。
ITCSS
ITCSS是CSS設(shè)計(jì)方法論,它并不是具體的CSS約束,他可以讓你更好的管理、維護(hù)你的項(xiàng)目的 CSS。

ITCSS 把 CSS 分成了以下的幾層
| Layer | 作用 |
|---|---|
| Settings | 項(xiàng)目使用的全局變量 |
| Tools | mixin,function |
| Generic | 最基本的設(shè)定 normalize.css,reset |
| Base | type selector |
| Objects | 不經(jīng)過裝飾 (Cosmetic-free) 的設(shè)計(jì)模式 |
| Components | UI 組件 |
| Trumps | helper 唯一可以使用 important! 的地方 |
以上是給的范式,我們不一定要完全按照它的方式,可以結(jié)合BEM和ACSS
目前我給出的CSS文件目錄(暫定)
└─styles
├───acss
├───generic
├───theme
├───tools
└───transition
BEM
即Block, Element, Modifier,是OOCSS(面向?qū)ο骳ss)的進(jìn)階版, 它是一種基于組件的web開發(fā)方法。blcok可以理解成獨(dú)立的塊,在頁(yè)面中該塊的移動(dòng)并不會(huì)影響到內(nèi)部樣式(和組件的概念類似,獨(dú)立的一塊),element就是塊下面的元素,和塊有著藕斷絲連的關(guān)系,modifier是表示樣式大小等。
我們來看一下element-ui的做法


我們項(xiàng)目組件的開發(fā)或者封裝統(tǒng)一使用BEM
ACSS
了解tailwind的人應(yīng)該對(duì)此設(shè)計(jì)模式不陌生,即原子級(jí)別的CSS。像.fr,.clearfix這種都屬于ACSS的設(shè)計(jì)思維。此處我們可以用此模式寫一些變量等。
JWT(json web token)
JWT是一種跨域認(rèn)證解決方案
http請(qǐng)求是無狀態(tài)的,服務(wù)器是不認(rèn)識(shí)前端發(fā)送的請(qǐng)求的。比如登錄,登錄成功之后服務(wù)端會(huì)生成一個(gè)sessionKey,sessionKey會(huì)寫入Cookie,下次請(qǐng)求的時(shí)候會(huì)自動(dòng)帶入sessionKey,現(xiàn)在很多都是把用戶ID寫到cookie里面。這是有問題的,比如要做單點(diǎn)登錄,用戶登錄A服務(wù)器的時(shí)候,服務(wù)器生成sessionKey,登錄B服務(wù)器的時(shí)候服務(wù)器沒有sessionKey,所以并不知道當(dāng)前登錄的人是誰(shuí),所以sessionKey做不到單點(diǎn)登錄。但是jwt由于是服務(wù)端生成的token給客戶端,存在客戶端,所以能實(shí)現(xiàn)單點(diǎn)登錄。
特點(diǎn)
由于使用的是json傳輸,所以JWT是跨語(yǔ)言的
便于傳輸,jwt的構(gòu)成非常簡(jiǎn)單,字節(jié)占用很小,所以它是非常便于傳輸?shù)?/p>
jwt會(huì)生成簽名,保證傳輸安全
jwt具有時(shí)效性
jwt更高效利用集群做好單點(diǎn)登錄
數(shù)據(jù)結(jié)構(gòu)
Header.Payload.Signature

數(shù)據(jù)安全
不應(yīng)該在jwt的payload部分存放敏感信息,因?yàn)樵摬糠质强蛻舳丝山饷艿牟糠?/p>
保護(hù)好secret私鑰,該私鑰非常重要
如果可以,請(qǐng)使用https協(xié)議
使用流程

image.png 使用方式
后端
const router = require('koa-router')()
const jwt = require('jsonwebtoken')
router.post('/login', async (ctx) => {
try {
const { userName, userPwd } = ctx.request.body
const res = await User.findOne({
userName,
userPwd
})
const data = res._doc
const token = jwt.sign({
data
}, 'secret', { expiresIn: '1h' })
if(res) {
data.token = token
ctx.body = data
}
} catch(e) {
}
} )前端
// axios請(qǐng)求攔截器,Cookie寫入token,請(qǐng)求頭添加:Authorization: Bearer `token`
service.interceptors.request.use(
request => {
const token = Cookies.get('token') // 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ'
token && (request.headers['Authorization'] = token)
return request
},
error => {
Message.error(error)
}
)后端驗(yàn)證有效性
const app = new Koa()
const router = require('koa-router')()
const jwt = require('jsonwebtoken')
const koajwt = require('koa-jwt')
// 使用koa-jwt中間件不用在接口之前攔截進(jìn)行校驗(yàn)
app.use(koajwt({ secret:'secret' }))
// 驗(yàn)證不通過會(huì)將http狀態(tài)碼返回401
app.use(async (ctx, next) => {
await next().catch(err => {
if(err.status === 401) {
ctx.body.msg = 'token認(rèn)證失敗'
}
})
})
菜單設(shè)計(jì)
關(guān)于菜單的生成方式有很多種,比較傳統(tǒng)的是前端維護(hù)一個(gè)菜單樹,根據(jù)后端返回的菜單樹進(jìn)行過濾。這種方式實(shí)際上提前將路由注冊(cè)進(jìn)入到實(shí)例中,這種現(xiàn)在其實(shí)已經(jīng)不是最佳實(shí)踐了。
現(xiàn)在主流的思路是后端通過XML來配置菜單,通過配置來生成菜單。前端登錄的時(shí)候拉取該角色對(duì)應(yīng)的菜單,通過addroute方法注冊(cè)菜單相應(yīng)的路由地址以及頁(yè)面在前端項(xiàng)目中的路徑等。這是比較主流的,但是我個(gè)人覺得不算最完美。
我們菜單和前端代碼其實(shí)是強(qiáng)耦合的,包括路由地址,頁(yè)面路徑,圖標(biāo),重定向等。項(xiàng)目初期菜單可能是經(jīng)常變化的,每次對(duì)菜單進(jìn)行添加或者修改等操作的時(shí)候,需要通知后端修改XML,并且后端的XML實(shí)際上就是沒有樹結(jié)構(gòu),看起來也不是很方便。
因此我采用如下設(shè)計(jì)模式,**前端**維護(hù)一份menu.json,所寫即所得,json數(shù)是什么樣在菜單配置的時(shí)候就是什么樣。
結(jié)構(gòu)設(shè)計(jì)
| key | type | description |
|---|---|---|
| title | string | 菜單的標(biāo)題 |
| name | string | 對(duì)應(yīng)路由的name,也是頁(yè)面或者按鈕的唯一標(biāo)識(shí),重要,看下面注意事項(xiàng) |
| type | string | MODULE代表模塊(子系統(tǒng),例如APP和后臺(tái)管理系統(tǒng)),MENU代表菜單,BUTTON代表按鈕 |
| path | string | 路徑,對(duì)應(yīng)路由的path |
| redirect | string | 重定向,對(duì)應(yīng)路由的redirect |
| icon | string | 菜單或者按鈕的圖標(biāo) |
| component | string | 當(dāng)作為才當(dāng)?shù)臅r(shí)候,對(duì)應(yīng)菜單的項(xiàng)目加載地址 |
| hidden | boolean | 當(dāng)作為菜單的時(shí)候是否在左側(cè)菜單樹隱藏 |
| noCache | boolean | 當(dāng)作為菜單的時(shí)候該菜單是否緩存 |
| fullscreen | boolean | 當(dāng)作為菜單的時(shí)候是否全屏顯示當(dāng)前菜單 |
| children | array | 顧名思義,下一級(jí) |
注意事項(xiàng):同級(jí)的name要是唯一的,實(shí)際使用中,每一級(jí)的name都是通過上一級(jí)的name用-拼接而來(會(huì)通過動(dòng)態(tài)導(dǎo)入章節(jié)演示name的生成規(guī)則),這樣可以保證每一個(gè)菜單或者按鈕項(xiàng)都有唯一的標(biāo)識(shí)。后續(xù)不論是做按鈕權(quán)限控制還是做菜單的緩存,都與此拼接的name有關(guān)。我們注意此時(shí)沒有id,后續(xù)會(huì)講到根據(jù)name全稱使用md5來生成id。
示例代碼
[
{
"title": "admin",
"name": "admin",
"type": "MODULE",
"children": [
{
"title": "中央控制臺(tái)",
"path": "/platform",
"name": "platform",
"type": "MENU",
"component": "/platform/index",
"icon": "mdi:monitor-dashboard"
},
{
"title": "系統(tǒng)設(shè)置",
"name": "system",
"type": "MENU",
"path": "/system",
"icon": "ri:settings-5-line",
"children": [
{
"title": "用戶管理",
"name": "user",
"type": "MENU",
"path": "user",
"component": "/system/user"
},
{
"title": "角色管理",
"name": "role",
"type": "MENU",
"path": "role",
"component": "/system/role"
},
{
"title": "資源管理",
"name": "resource",
"type": "MENU",
"path": "resource",
"component": "/system/resource"
}
]
},
{
"title": "實(shí)用功能",
"name": "function",
"type": "MENU",
"path": "/function",
"icon": "ri:settings-5-line",
"children": []
}
]
}
]
生成的菜單樹
如果覺得所有頁(yè)面的路由寫在一個(gè)頁(yè)面中太長(zhǎng),難以維護(hù)的話,可以把
json換成js用import機(jī)制,這里涉及到的變動(dòng)比較多,暫時(shí)先不提及
使用時(shí),我們分development和production兩種環(huán)境
development:該模式下,菜單樹直接讀取menu.json文件production:該模式下,菜單樹通過接口獲取數(shù)據(jù)庫(kù)的數(shù)據(jù)
如何存到數(shù)據(jù)庫(kù)
OK,我們之前提到過,菜單是由前端通過menu.json來維護(hù)的,那怎么進(jìn)到數(shù)據(jù)庫(kù)中呢?實(shí)際上,我的設(shè)計(jì)是通過node讀取menu.json文件,然后創(chuàng)建SQL語(yǔ)句,交給后端放到liquibase中,這樣不管有多少個(gè)數(shù)據(jù)庫(kù)環(huán)境,后端只要拿到該SQL語(yǔ)句,就能在多個(gè)環(huán)境創(chuàng)建菜單數(shù)據(jù)。當(dāng)然,由于json是可以跨語(yǔ)言通信的,所以我們可以直接把json文件丟給后端,或者把項(xiàng)目json路徑丟給運(yùn)維,通過CI/CD工具完成自動(dòng)發(fā)布。
nodejs生成SQL示例
// createMenu.js
/**
*
* =================MENU CONFIG======================
*
* this javascript created to genarate SQL for Java
*
* ====================================================
*
*/
const fs = require('fs')
const path = require('path')
const chalk = require('chalk')
const execSync = require('child_process').execSync //同步子進(jìn)程
const resolve = (dir) => path.join(__dirname, dir)
const moment = require('moment')
// get the Git user name to trace who exported the SQL
const gitName = execSync('git show -s --format=%cn').toString().trim()
const md5 = require('md5')
// use md5 to generate id
/* =========GLOBAL CONFIG=========== */
// 導(dǎo)入路徑
const INPUT_PATH = resolve('src/router/menu.json')
// 導(dǎo)出的文件目錄位置
const OUTPUT_PATH = resolve('./menu.sql')
// 表名
const TABLE_NAME = 't_sys_menu'
/* =========GLOBAL CONFIG=========== */
function createSQL(data, name = '', pid, arr = []) {
data.forEach(function (v, d) {
if (v.children && v.children.length) {
createSQL(v.children, name + '-' + v.name, v.id, arr)
}
arr.push({
id: v.id || md5(v.name), // name is unique,so we can use name to generate id
created_at: moment().format('YYYY-MM-DD HH:mm:ss'),
modified_at: moment().format('YYYY-MM-DD HH:mm:ss'),
created_by: gitName,
modified_by: gitName,
version: 1,
is_delete: false,
code: (name + '-' + v.name).slice(1),
name: v.name,
title: v.title,
icon: v.icon,
path: v.path,
sort: d + 1,
parent_id: pid,
type: v.type,
component: v.component,
redirect: v.redirect,
full_screen: v.fullScreen || false,
hidden: v.hidden || false,
no_cache: v.noCache || false
})
})
return arr
}
fs.readFile(INPUT_PATH, 'utf-8', (err, data) => {
if (err) chalk.red(err)
const menuList = createSQL(JSON.parse(data))
const sql = menuList
.map((sql) => {
let value = ''
for (const v of Object.values(sql)) {
value += ','
if (v === true) {
value += 1
} else if (v === false) {
value += 0
} else {
value += v ? `'${v}'` : null
}
}
return 'INSERT INTO `' + TABLE_NAME + '` VALUES (' + value.slice(1) + ')' + '\n'
})
.join(';')
const mySQL =
'DROP TABLE IF EXISTS `' +
TABLE_NAME +
'`;' +
'\n' +
'CREATE TABLE `' +
TABLE_NAME +
'` (' +
'\n' +
'`id` varchar(64) NOT NULL,' +
'\n' +
"`created_at` timestamp NULL DEFAULT NULL COMMENT '創(chuàng)建時(shí)間'," +
'\n' +
"`modified_at` timestamp NULL DEFAULT NULL COMMENT '更新時(shí)間'," +
'\n' +
"`created_by` varchar(64) DEFAULT NULL COMMENT '創(chuàng)建人'," +
'\n' +
"`modified_by` varchar(64) DEFAULT NULL COMMENT '更新人'," +
'\n' +
"`version` int(11) DEFAULT NULL COMMENT '版本(樂觀鎖)'," +
'\n' +
"`is_delete` int(11) DEFAULT NULL COMMENT '邏輯刪除'," +
'\n' +
"`code` varchar(150) NOT NULL COMMENT '編碼'," +
'\n' +
"`name` varchar(50) DEFAULT NULL COMMENT '名稱'," +
'\n' +
"`title` varchar(50) DEFAULT NULL COMMENT '標(biāo)題'," +
'\n' +
"`icon` varchar(50) DEFAULT NULL COMMENT '圖標(biāo)'," +
'\n' +
"`path` varchar(250) DEFAULT NULL COMMENT '路徑'," +
'\n' +
"`sort` int(11) DEFAULT NULL COMMENT '排序'," +
'\n' +
"`parent_id` varchar(64) DEFAULT NULL COMMENT '父id'," +
'\n' +
"`type` char(10) DEFAULT NULL COMMENT '類型'," +
'\n' +
"`component` varchar(250) DEFAULT NULL COMMENT '組件路徑'," +
'\n' +
"`redirect` varchar(250) DEFAULT NULL COMMENT '重定向路徑'," +
'\n' +
"`full_screen` int(11) DEFAULT NULL COMMENT '全屏'," +
'\n' +
"`hidden` int(11) DEFAULT NULL COMMENT '隱藏'," +
'\n' +
"`no_cache` int(11) DEFAULT NULL COMMENT '緩存'," +
'\n' +
'PRIMARY KEY (`id`),' +
'\n' +
'UNIQUE KEY `code` (`code`) USING BTREE' +
'\n' +
") ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='資源';" +
'\n' +
sql
fs.writeFile(OUTPUT_PATH, mySQL, (err) => {
if (err) return chalk.red(err)
console.log(chalk.cyanBright(`恭喜你,創(chuàng)建sql語(yǔ)句成功,位置:${OUTPUT_PATH}`))
})
})
注意上面是通過使用
md5對(duì)name進(jìn)行加密生成主鍵id到數(shù)據(jù)庫(kù)中
我們嘗試用node執(zhí)行該js
node createMenu.js


由于生產(chǎn)環(huán)境不會(huì)直接引入menu.json,因此經(jīng)過打包編譯的線上環(huán)境不會(huì)存在該文件,因此也不會(huì)有安全性問題
如何控制到按鈕級(jí)別
我們知道,按鈕(這里的按鈕是廣義上的,對(duì)于前端來說可能是button,tab,dropdown等一切可以控制的內(nèi)容)的載體一定是頁(yè)面,因此按鈕可以直接掛在到menu樹的MENU類型的資源下面,沒有頁(yè)面頁(yè)面權(quán)限當(dāng)然沒有該頁(yè)面下的按鈕權(quán)限,有頁(yè)面權(quán)限的情況下,我們通過v-permission指令來控制按鈕的顯示
示例代碼
// 生成權(quán)限按鈕表存到store
const createPermissionBtns = router => {
let btns = []
const c = (router, name = '') => {
router.forEach(v => {
v.type === 'BUTTON' && btns.push((name + '-' + v.name).slice(1))
return v.children && v.children.length ? c(v.children, name + '-' + v.name) : null
})
return btns
}
return c(router)
}
// 權(quán)限控制
Vue.directive('permission', {
// 這里是vue3的寫法,vue2請(qǐng)使用inserted生命周期
mounted(el, binding, vnode) {
// 獲取this
const { context: vm } = vnode
// 獲取綁定的值
const name = vm.$options.name + '-' + binding.value
// 獲取權(quán)限表
const {
state: { permissionBtns }
} = store
// 如果沒有權(quán)限那就移除
if (permissionBtns.indexOf(name) === -1) {
el.parentNode.removeChild(el)
}
}
})
<el-button type="text" v-permission="'edit'" @click="edit(row.id)">編輯</el-button>
假設(shè)當(dāng)前頁(yè)面的name值是system-role,按鈕的name值是system-role-edit,那么通過此指令就可以很方便的控制到按鈕的權(quán)限
動(dòng)態(tài)導(dǎo)入
我們json或者接口配置的路由前端頁(yè)面地址,在vue-router中又是如何注冊(cè)進(jìn)去的呢?
注意以下name的生成規(guī)則,以角色菜單為例,name拼接出的形式大致為:
一級(jí)菜單:system 二級(jí)菜單:system-role 該二級(jí)菜單下的按鈕:system-role-edit
vue-clivue-cli3及以上可以直接使用 webpack4+引入的dynamic import// 生成可訪問的路由表
const generateRoutes = (routes, cname = '') => {
return routes.reduce((prev, { type, uri: path, componentPath, name, title, icon, redirectUri: redirect, hidden, fullScreen, noCache, children = [] }) => {
// 是菜單項(xiàng)就注冊(cè)到路由進(jìn)去
if (type === 'MENU') {
prev.push({
path,
component: () => import(`@/${componentPath}`),
name: (cname + '-' + name).slice(1),
props: true,
redirect,
meta: { title, icon, hidden, type, fullScreen, noCache },
children: children.length ? createRouter(children, cname + '-' + name) : []
})
}
return prev
}, [])
}vitevite2之后可以直接使用glob-import// dynamicImport.ts
export default function dynamicImport(component: string) {
const dynamicViewsModules = import.meta.glob('../../views/**/*.{vue,tsx}')
const keys = Object.keys(dynamicViewsModules)
const matchKeys = keys.filter((key) => {
const k = key.replace('../../views', '')
return k.startsWith(`${component}`) || k.startsWith(`/${component}`)
})
if (matchKeys?.length === 1) {
const matchKey = matchKeys[0]
return dynamicViewsModules[matchKey]
}
if (matchKeys?.length > 1) {
console.warn(
'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure'
)
return
}
return null
}
import type { IResource, RouteRecordRaw } from '../types'
import dynamicImport from './dynamicImport'
// 生成可訪問的路由表
const generateRoutes = (routes: IResource[], cname = '', level = 1): RouteRecordRaw[] => {
return routes.reduce((prev: RouteRecordRaw[], curr: IResource) => {
// 如果是菜單項(xiàng)則注冊(cè)進(jìn)來
const { id, type, path, component, name, title, icon, redirect, hidden, fullscreen, noCache, children } = curr
if (type === 'MENU') {
// 如果是一級(jí)菜單沒有子菜單,則掛在在app路由下面
if (level === 1 && !(children && children.length)) {
prev.push({
path,
component: dynamicImport(component!),
name,
props: true,
meta: { id, title, icon, type, parentName: 'app', hidden: !!hidden, fullscreen: !!fullscreen, noCache: !!noCache }
})
} else {
prev.push({
path,
component: component ? dynamicImport(component) : () => import('/@/layouts/dashboard'),
name: (cname + '-' + name).slice(1),
props: true,
redirect,
meta: { id, title, icon, type, hidden: !!hidden, fullscreen: !!fullscreen, noCache: !!noCache },
children: children?.length ? generateRoutes(children, cname + '-' + name, level + 1) : []
})
}
}
return prev
}, [])
}
export default generateRoutes
動(dòng)態(tài)注冊(cè)路由
要實(shí)現(xiàn)動(dòng)態(tài)添加路由,即只有有權(quán)限的路由才會(huì)注冊(cè)到Vue實(shí)例中??紤]到每次刷新頁(yè)面的時(shí)候由于vue的實(shí)例會(huì)丟失,并且角色的菜單也可能會(huì)更新,因此在每次加載頁(yè)面的時(shí)候做菜單的拉取和路由的注入是最合適的時(shí)機(jī)。因此核心是vue-router的addRoute和導(dǎo)航守衛(wèi)beforeEach兩個(gè)方法
要實(shí)現(xiàn)動(dòng)態(tài)添加路由,即只有有權(quán)限的路由才會(huì)注冊(cè)到Vue實(shí)例中??紤]到每次刷新頁(yè)面的時(shí)候由于vue的實(shí)例會(huì)丟失,并且角色的菜單也可能會(huì)更新,因此在每次加載頁(yè)面的時(shí)候做菜單的拉取和路由的注入是最合適的時(shí)機(jī)。因此核心是vue-router的addRoute和導(dǎo)航鉤子beforeEach兩個(gè)方法
vue-router3x
注:3.5.0API也更新到了addRoute,注意區(qū)分版本變化
vue-router4x
個(gè)人更傾向于使用vue-router4x的addRoute方法,這樣可以更精細(xì)的控制每一個(gè)路由的的定位
大體思路為,在beforeEach該導(dǎo)航守衛(wèi)中(即每次路由跳轉(zhuǎn)之前做判斷),如果已經(jīng)授權(quán)過(authorized),就直接進(jìn)入next方法,如果沒有,則從后端拉取路由表注冊(cè)到實(shí)例中。(直接在入口文件main.js中引入以下文件或代碼)
// permission.js
router.beforeEach(async (to, from, next) => {
const token = Cookies.get('token')
if (token) {
if (to.path === '/login') {
next({ path: '/' })
} else {
if (!store.state.authorized) {
// set authority
await store.dispatch('setAuthority')
// it's a hack func,avoid bug
next({ ...to, replace: true })
} else {
next()
}
}
} else {
if (to.path !== '/login') {
next({ path: '/login' })
} else {
next(true)
}
}
})
由于路由是動(dòng)態(tài)注冊(cè)的,所以項(xiàng)目的初始路由就會(huì)很簡(jiǎn)潔,只要提供靜態(tài)的不需要權(quán)限的基礎(chǔ)路由,其他路由都是從服務(wù)器返回之后動(dòng)態(tài)注冊(cè)進(jìn)來的
// router.js
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from './types'
// static modules
import Login from '/@/views/sys/Login.vue'
import NotFound from '/@/views/sys/NotFound.vue'
import Homepage from '/@/views/sys/Homepage.vue'
import Layout from '/@/layouts/dashboard'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/homepage'
},
{
path: '/login',
component: Login
},
// for 404 page
{
path: '/:pathMatch(.*)*',
component: NotFound
},
// to place the route who don't have children
{
path: '/app',
component: Layout,
name: 'app',
children: [{ path: '/homepage', component: Homepage, name: 'homepage', meta: { title: '首頁(yè)' } }]
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
// always scroll to top
return { top: 0 }
}
})
export default router
左側(cè)菜單樹和按鈕生成
其實(shí)只要遞歸拿到type為MENU的資源注冊(cè)到路由,過濾掉hidden:true的菜單在左側(cè)樹顯示,此處不再贅述。
RBAC(Role Based Access Control)
RBAC 是基于角色的訪問控制(Role-Based Access Control )在 RBAC 中,權(quán)限與角色相關(guān)聯(lián),用戶通過成為適當(dāng)角色的成員而得到這些角色的權(quán)限。這就極大地簡(jiǎn)化了權(quán)限的管理。這樣管理都是層級(jí)相互依賴的,權(quán)限賦予給角色,而把角色又賦予用戶,這樣的權(quán)限設(shè)計(jì)很清楚,管理起來很方便。
這樣登錄的時(shí)候只要獲取用戶
用戶選擇角色
角色綁定菜單
菜單
頁(yè)面緩存控制
頁(yè)面緩存,聽起來無關(guān)緊要的功能,卻能給客戶帶來極大的使用體驗(yàn)的提升。
例如我們有一個(gè)分頁(yè)列表,輸入某個(gè)查詢條件之后篩選出某一條數(shù)據(jù),點(diǎn)開詳情之后跳轉(zhuǎn)到新的頁(yè)面,關(guān)閉詳情返回分頁(yè)列表的頁(yè)面,假如之前查詢的狀態(tài)不存在,用戶需要重復(fù)輸入查詢條件,這不僅消耗用戶的耐心,也增加了服務(wù)器不必要的壓力。
因此,緩存控制在系統(tǒng)里面很有存在的價(jià)值,我們知道vue有keep-alive組件可以讓我們很方便的進(jìn)行緩存,那么是不是我們直接把根組件直接用keep-alive包裝起來就好了呢?
實(shí)際上這樣做是不合適的,比如我有個(gè)用戶列表,打開小明和小紅的詳情頁(yè)都給他緩存起來,由于緩存是寫入內(nèi)存的,用戶使用系統(tǒng)久了之后必將導(dǎo)致系統(tǒng)越來越卡。并且類似于詳情頁(yè)這種數(shù)據(jù)應(yīng)該是每次打開的時(shí)候都從接口獲取一次才能保證是最新的數(shù)據(jù),將它也緩存起來本身就是不合適的。那么按需緩存就是我們系統(tǒng)迫切需要使用的,好在keep-alive給我們提供了include這個(gè)api

注意這個(gè)include存的是頁(yè)面的name,不是路由的name
因此,如何定義頁(yè)面的name是很關(guān)鍵的
我的做法是,vue頁(yè)面的name值與當(dāng)前的menu.json的層級(jí)相連的name(實(shí)際上經(jīng)過處理就是注冊(cè)路由的時(shí)候的全路徑name)對(duì)應(yīng),參考動(dòng)態(tài)導(dǎo)入的介紹,這樣做用兩個(gè)目的:
我們知道vue的緩存組件 keep-alive的include選項(xiàng)是基于頁(yè)面的name來緩存的,我們使路由的name和頁(yè)面的name保持一致,這樣我們一旦路由發(fā)生變化,我們將所有路由的name存到store中,也就相當(dāng)于存了頁(yè)面的name到了store中,這樣做緩存控制會(huì)很方便。當(dāng)然頁(yè)面如果不需要緩存,可以在menu.json中給這個(gè)菜單noCache設(shè)置為true,這也是我們菜單表結(jié)構(gòu)中該字段的由來。我們開發(fā)的時(shí)候一般都會(huì)安裝 vue-devtools進(jìn)行調(diào)試,語(yǔ)義化的name值方便進(jìn)行調(diào)試。
例如角色管理
對(duì)應(yīng)的json位置
對(duì)應(yīng)的vue文件
對(duì)應(yīng)的vue-devtools
為了更好的用戶體驗(yàn),我們?cè)谙到y(tǒng)里面使用tag來記錄用戶之前點(diǎn)開的頁(yè)面的狀態(tài)。其實(shí)這也是一個(gè)hack手段,無非是解決SPA項(xiàng)目的一個(gè)痛點(diǎn)。
效果圖
大概思路就是監(jiān)聽路由變化,把所有路由的相關(guān)信息存到store中。根據(jù)該路由的noCache字段顯示不同的小圖標(biāo),告訴用戶這個(gè)路由是否是帶有緩存的路由。
組件的封裝或者基于UI庫(kù)的二次封裝
組件的封裝原則無非就是復(fù)用,可擴(kuò)展。
我們?cè)谧畛醴庋b組件的時(shí)候不用追求過于完美,滿足基礎(chǔ)的業(yè)務(wù)場(chǎng)景即可。后續(xù)根據(jù)需求變化再去慢慢完善組件。
如果是多人團(tuán)隊(duì)的大型項(xiàng)目還是建議使用Jest做好單元測(cè)試配合storybook生成組件文檔。
關(guān)于組件的封裝技巧,網(wǎng)上有很多詳細(xì)的教程,本人經(jīng)驗(yàn)有限,這里就不再討論。
使用plop創(chuàng)建模板
基本框架搭建完畢,組件也封裝好了之后,剩下的就是碼業(yè)務(wù)功能了。
對(duì)于中后臺(tái)管理系統(tǒng),業(yè)務(wù)部分大部分離不開CRUD,我們看到上面的截圖,類似用戶,角色等菜單,組成部分都大同小異,前端部分只要封裝好組件(列表,表單,彈框等),頁(yè)面都可以直接通過模板來生成。甚至現(xiàn)在有很多可視化配置工具(低代碼),我個(gè)人覺得目前不太適合專業(yè)前端,因?yàn)楹芏鄨?chǎng)景下頁(yè)面的組件都是基于業(yè)務(wù)封裝的,單純的把UI庫(kù)原生組件搬過來沒有意義。當(dāng)然時(shí)間充足的話,可以自己在項(xiàng)目上用node開發(fā)低代碼的工具。
這里我們可以配合inquirer-directory來在控制臺(tái)選擇目錄
plopfile.js
const promptDirectory = require('inquirer-directory')
const pageGenerator = require('./template/page/prompt')
const apisGenerator = require('./template/apis/prompt')
module.exports = function (plop) {
plop.setPrompt('directory', promptDirectory)
plop.setGenerator('page', pageGenerator)
plop.setGenerator('apis', apisGenerator)
}
一般情況下, 我們和后臺(tái)定義好restful規(guī)范的接口之后,每當(dāng)有新的業(yè)務(wù)頁(yè)面的時(shí)候,我們要做兩件事情,一個(gè)是寫好接口配置,一個(gè)是寫頁(yè)面,這兩個(gè)我們可以通過模板來創(chuàng)建了。我們使用hbs來創(chuàng)建。
api.hbs
import request from '../request'
{{#if create}}
// Create
export const create{{ properCase name }} = (data: any) => request.post('{{camelCase name}}/', data)
{{/if}}
{{#if delete}}
// Delete
export const remove{{ properCase name }} = (id: string) => request.delete(`{{camelCase name}}/${id}`)
{{/if}}
{{#if update}}
// Update
export const update{{ properCase name }} = (id: string, data: any) => request.put(`{{camelCase name}}/${id}`, data)
{{/if}}
{{#if get}}
// Retrieve
export const get{{ properCase name }} = (id: string) => request.get(`{{camelCase name}}/${id}`)
{{/if}}
{{#if check}}
// Check Unique
export const check{{ properCase name }} = (data: any) => request.post(`{{camelCase name}}/check`, data)
{{/if}}
{{#if fetchList}}
// List query
export const fetch{{ properCase name }}List = (params: any) => request.get('{{camelCase name}}/list', { params })
{{/if}}
{{#if fetchPage}}
// Page query
export const fetch{{ properCase name }}Page = (params: any) => request.get('{{camelCase name}}/page', { params })
{{/if}}
prompt.js
const { notEmpty } = require('../utils.js')
const path = require('path')
// 斜杠轉(zhuǎn)駝峰
function toCamel(str) {
return str.replace(/(.*)\/(\w)(.*)/g, function (_, $1, $2, $3) {
return $1 + $2.toUpperCase() + $3
})
}
// 選項(xiàng)框
const choices = ['create', 'update', 'get', 'delete', 'check', 'fetchList', 'fetchPage'].map((type) => ({
name: type,
value: type,
checked: true
}))
module.exports = {
description: 'generate api template',
prompts: [
{
type: 'directory',
name: 'from',
message: 'Please select the file storage address',
basePath: path.join(__dirname, '../../src/apis')
},
{
type: 'input',
name: 'name',
message: 'api name',
validate: notEmpty('name')
},
{
type: 'checkbox',
name: 'types',
message: 'api types',
choices
}
],
actions: (data) => {
const { from, name, types } = data
const actions = [
{
type: 'add',
path: path.join('src/apis', from, toCamel(name) + '.ts'),
templateFile: 'template/apis/index.hbs',
data: {
name,
create: types.includes('create'),
update: types.includes('update'),
get: types.includes('get'),
check: types.includes('check'),
delete: types.includes('delete'),
fetchList: types.includes('fetchList'),
fetchPage: types.includes('fetchPage')
}
}
]
return actions
}
}
我們來執(zhí)行plop
通過inquirer-directory,我們可以很方便的選擇系統(tǒng)目錄
輸入name名,一般對(duì)應(yīng)后端的controller名稱
使用空格來選擇每一項(xiàng),使用回車來確認(rèn)
最終生成的文件
生成頁(yè)面的方式與此類似,我這邊也只是拋磚引玉,相信大家能把它玩出花來
項(xiàng)目地址levi-vue-admin:https://github.com/sky124380729/levi-vue-admin
作者:sky124380729
https://segmentfault.com/a/1190000040096254
點(diǎn)贊和在看就是最大的支持
