vue-cli
基於webpack
封裝,生態很是強大,可配置性也很是高,幾乎可以知足前端工程化的全部要求。缺點就是配置複雜,甚至有公司有專門的webpack工程師
專門作配置,另外就是webpack因爲開發環境須要打包編譯,開發體驗實際上不如vite
。vite
開發模式基於esbuild
,打包使用的是rollup
。急速的冷啓動
和無縫的hmr
在開發模式下得到極大的體驗提高。缺點就是該腳手架剛起步,生態上還不及webpack
。本文主要講解使用vite
來做爲腳手架開發。(動手能力強的小夥伴徹底可使用vite
作開發服務器,使用webpack
作打包編譯放到生產環境)javascript
爲何選擇vite而不是vue-cli,不管是webpack
,parcel
,rollup
等工具,雖然都極大的提升了前端的開發體驗,可是都有一個問題,就是當項目愈來愈大的時候,須要處理的js
代碼也呈指數級增加,打包過程一般須要很長時間(甚至是幾分鐘!)才能啓動開發服務器,體驗會隨着項目愈來愈大而變得愈來愈差。css
因爲現代瀏覽器都已經原生支持es模塊,咱們只要使用支持esm的瀏覽器開發,那麼是否是咱們的代碼就不須要打包了?是的,原理就是這麼簡單。vite將源碼模塊的請求會根據304 Not Modified
進行協商緩存,依賴模塊經過Cache-Control:max-age=31536000,immutable
進行協商緩存,所以一旦被緩存它們將不須要再次請求。html
軟件巨頭微軟週三(5月19日)表示,從2022年6月15日起,公司某些版本的Windows軟件將再也不支持當前版本的IE 11桌面應用程序。
因此利用瀏覽器的最新特性來開發項目是趨勢。
$ npm init @vitejs/app <project-name> $ cd <project-name> $ npm install $ npm run dev
vscode 安裝 eslint
,prettier
,vetur
(喜歡用vue3 setup語法糖可使用volar
,這時要禁用vetur
)前端
打開vscode eslint vue
yarn add --dev eslint eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin
yarn add --dev prettier eslint-config-prettier eslint-plugin-prettier
module.exports = { printWidth: 180, //一行的字符數,若是超過會進行換行,默認爲80 tabWidth: 4, //一個tab表明幾個空格數,默認爲80 useTabs: false, //是否使用tab進行縮進,默認爲false,表示用空格進行縮減 singleQuote: true, //字符串是否使用單引號,默認爲false,使用雙引號 semi: false, //行位是否使用分號,默認爲true trailingComma: 'none', //是否使用尾逗號,有三個可選值"<none|es5|all>" bracketSpacing: true, //對象大括號直接是否有空格,默認爲true,效果:{ foo: bar } jsxSingleQuote: true, // jsx語法中使用單引號 endOfLine: 'auto' }
//.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' ] }
{ "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "eslint.validate": [ "javascript", "javascriptreact", "vue", "typescript", "typescriptreact", "json" ] }
├─.vscode // vscode配置文件 ├─public // 無需編譯的靜態資源目錄 ├─src // 代碼源文件目錄 │ ├─apis // apis統一管理 │ │ └─modules // api模塊 │ ├─assets // 靜態資源 │ │ └─images │ ├─components // 項目組件目錄 │ │ ├─Form │ │ ├─Input │ │ ├─Message │ │ ├─Search │ │ ├─Table │ ├─directives // 指令目錄 │ │ └─print │ ├─hooks // hooks目錄 │ ├─layouts // 佈局組件 │ │ ├─dashboard │ │ │ ├─content │ │ │ ├─header │ │ │ └─sider │ │ └─fullpage │ ├─mock // mock apu存放地址,和apis對應 │ │ └─modules │ ├─router // 路由相關 │ │ └─helpers │ ├─store // 狀態管理相關 │ ├─styles // 樣式相關(後面降到css架構會涉及具體的目錄) │ ├─types // 類型定義相關 │ ├─utils // 工具類相關 │ └─views // 頁面目錄地址 │ ├─normal │ └─system └─template // 模板相關 ├─apis └─page
ITCSS
+ BEM
+ ACSS
現實開發中,咱們常常忽視CSS的架構設計。前期對樣式架構的忽略,隨着項目的增大,致使出現樣式污染,覆蓋,難以追溯,代碼重複等各類問題。所以,CSS架構設計一樣須要重視起來。java
ITCSS
ITCSS 把 CSS 分紅了如下的幾層node
Layer | 做用 |
---|---|
Settings | 項目使用的全局變量 |
Tools | mixin,function |
Generic | 最基本的設定 normalize.css,reset |
Base | type selector |
Objects | 不通過裝飾 (Cosmetic-free) 的設計模式 |
Components | UI 組件 |
Trumps | helper 惟一可使用 important! 的地方 |
以上是給的範式,咱們不必定要徹底按照它的方式,能夠結合BEM
和ACSS
react
目前我給出的CSS文件目錄(暫定)
└─styleswebpack
├───acss ├───base ├───settings ├───theme └───tools
BEM
OOCSS
(面向對象css)的進階版, 它是一種基於組件的web開發方法。blcok能夠理解成獨立的塊,在頁面中該塊的移動並不會影響到內部樣式(和組件的概念相似,獨立的一塊),element就是塊下面的元素,和塊有着藕斷絲連的關係,modifier是表示樣式大小等。element-ui
的作法咱們項目組件的開發或者封裝統一使用BEM
ios
ACSS
tailwind
的人應該對此設計模式不陌生,即原子級別的CSS。像.fr,.clearfix這種都屬於ACSS的設計思惟。此處咱們能夠用此模式寫一些變量等。JWT是一種跨域認證解決方案
http請求是無狀態的,服務器是不認識前端發送的請求的。好比登陸,登陸成功以後服務端會生成一個sessionKey,sessionKey會寫入Cookie,下次請求的時候會自動帶入sessionKey,如今不少都是把用戶ID寫到cookie裏面。這是有問題的,好比要作單點登陸,用戶登陸A服務器的時候,服務器生成sessionKey,登陸B服務器的時候服務器沒有sessionKey,因此並不知道當前登陸的人是誰,因此sessionKey作不到單點登陸。可是jwt因爲是服務端生成的token給客戶端,存在客戶端,因此能實現單點登陸。
jwt更高效利用集羣作好單點登陸
若是能夠,請使用https協議
後端
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請求攔截器,Cookie寫入token,請求頭添加: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) } )
後端驗證有效性
const app = new Koa() const router = require('koa-router')() const jwt = require('jsonwebtoken') const koajwt = require('koa-jwt') // 使用koa-jwt中間件不用在接口以前攔截進行校驗 app.use(koajwt({ secret:'secret' })) // 驗證不經過會將http狀態碼返回401 app.use(async (ctx, next) => { await next().catch(err => { if(err.status === 401) { ctx.body.msg = 'token認證失敗' } }) })
關於菜單的生成方式有不少種,比較傳統的是前端維護一個菜單樹,根據後端返回的菜單樹進行過濾。這種方式實際上提早將路由註冊進入到實例中,這種如今其實已經不是最佳實踐了。
如今主流的思路是後端經過XML
來配置菜單,經過配置來生成菜單。前端登陸的時候拉取該角色對應的菜單,經過addroute
方法註冊菜單相應的路由地址以及頁面在前端項目中的路徑等。這是比較主流的,可是我我的以爲不算最完美。
咱們菜單和前端代碼實際上是強耦合的,包括路由地址,頁面路徑,圖標,重定向等。項目初期菜單多是常常變化的,每次對菜單進行添加或者修改等操做的時候,須要通知後端修改XML
,而且後端的XML
實際上就是沒有樹結構,看起來也不是很方便。
所以我採用以下設計模式,前端
維護一份menu.json
,所寫即所得,json數是什麼樣在菜單配置的時候就是什麼樣。
key | type | description |
---|---|---|
title | string | 菜單的標題 |
name | string | 對應路由的name,也是頁面或者按鈕的惟一標識,重要,看下面注意事項 |
type | string | MODULE 表明模塊(子系統,例如APP和後臺管理系統),MENU 表明菜單,BUTTON 表明按鈕 |
path | string | 路徑,對應路由的path |
redirect | string | 重定向,對應路由的redirect |
icon | string | 菜單或者按鈕的圖標 |
component | string | 看成爲才當的時候,對應菜單的項目加載地址 |
hidden | boolean | 看成爲菜單的時候是否在左側菜單樹隱藏 |
noCache | boolean | 看成爲菜單的時候該菜單是否緩存 |
fullscreen | boolean | 看成爲菜單的時候是否全屏顯示當前菜單 |
children | array | 顧名思義,下一級 |
注意事項
:同級的name要是惟一的,實際使用中,每一級的name都是經過上一級的name用-
拼接而來(會經過 動態導入章節演示name的生成規則),這樣能夠保證每個菜單或者按鈕項都有惟一的標識。後續不管是作按鈕權限控制仍是作菜單的緩存,都與此拼接的name有關。咱們注意此時沒有id,後續會講到根據name全稱使用md5來生成id。
示例代碼
[ { "title": "admin", "name": "admin", "type": "MODULE", "children": [ { "title": "中央控制檯", "path": "/platform", "name": "platform", "type": "MENU", "component": "/platform/index", "icon": "mdi:monitor-dashboard" }, { "title": "系統設置", "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": "實用功能", "name": "function", "type": "MENU", "path": "/function", "icon": "ri:settings-5-line", "children": [] } ] } ]
生成的菜單樹
若是以爲全部頁面的路由寫在一個頁面中太長,難以維護的話,能夠把json
換成js用import
機制,這裏涉及到的變更比較多,暫時先不說起
使用時,咱們分development
和production
兩種環境
development
:該模式下,菜單樹直接讀取menu.json文件production
:該模式下,菜單樹經過接口獲取數據庫的數據OK,咱們以前提到過,菜單是由前端經過menu.json來維護的,那怎麼進到數據庫中呢?實際上,個人設計是經過node
讀取menu.json
文件,而後建立SQL語句,交給後端放到liquibase
中,這樣無論有多少個數據庫環境,後端只要拿到該SQL語句,就能在多個環境建立菜單數據。固然,因爲json
是能夠跨語言通訊的,因此咱們能夠直接把json
文件丟給後端,或者把項目json
路徑丟給運維,經過CI/CD
工具完成自動發佈。
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 //同步子進程 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=========== */ // 導入路徑 const INPUT_PATH = resolve('src/router/menu.json') // 導出的文件目錄位置 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 '建立時間'," + '\n' + "`modified_at` timestamp NULL DEFAULT NULL COMMENT '更新時間'," + '\n' + "`created_by` varchar(64) DEFAULT NULL COMMENT '建立人'," + '\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 '標題'," + '\n' + "`icon` varchar(50) DEFAULT NULL COMMENT '圖標'," + '\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(`恭喜你,建立sql語句成功,位置:${OUTPUT_PATH}`)) }) })
注意上面是經過使用md5
對name
進行加密生成主鍵id
到數據庫中
咱們嘗試用node執行該js
node createMenu.js
因爲生產環境不會直接引入menu.json
,所以通過打包編譯的線上環境不會存在該文件,所以也不會有安全性問題
咱們知道,按鈕(這裏的按鈕是廣義上的,對於前端來講多是button,tab,dropdown等一切能夠控制的內容)的載體必定是頁面,所以按鈕能夠直接掛在到menu樹的MENU
類型的資源下面,沒有頁面頁面權限固然沒有該頁面下的按鈕權限,有頁面權限的狀況下,咱們經過v-permission
指令來控制按鈕的顯示
示例代碼
// 生成權限按鈕表存到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) }
// 權限控制 Vue.directive('permission', { // 這裏是vue3的寫法,vue2請使用inserted生命週期 mounted(el, binding, vnode) { // 獲取this const { context: vm } = vnode // 獲取綁定的值 const name = vm.$options.name + '-' + binding.value // 獲取權限表 const { state: { permissionBtns } } = store // 若是沒有權限那就移除 if (permissionBtns.indexOf(name) === -1) { el.parentNode.removeChild(el) } } })
<el-button type="text" v-permission="'edit'" @click="edit(row.id)">編輯</el-button>
假設當前頁面的name值是system-role
,按鈕的name值是system-role-edit
,那麼經過此指令就能夠很方便的控制到按鈕的權限
咱們json
或者接口配置的路由前端頁面地址,在vue-router
中又是如何註冊進去的呢?
注意如下name的生成規則,以角色菜單爲例,name拼接出的形式大體爲:
- 一級菜單:system
- 二級菜單:system-role
- 該二級菜單下的按鈕:system-role-edit
vue-cli
vue-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 = [] }) => { // 是菜單項就註冊到路由進去 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 }, []) }
vite
vite2以後能夠直接使用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) => { // 若是是菜單項則註冊進來 const { id, type, path, component, name, title, icon, redirect, hidden, fullscreen, noCache, children } = curr if (type === 'MENU') { // 若是是一級菜單沒有子菜單,則掛在在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
要實現動態添加路由,即只有有權限的路由纔會註冊到Vue實例中。考慮到每次刷新頁面的時候因爲vue的實例會丟失,而且角色的菜單也可能會更新,所以在每次加載頁面的時候作菜單的拉取和路由的注入是最合適的時機。所以核心是vue-router
的addRoute
和導航守衛beforeEach
兩個方法
要實現動態添加路由,即只有有權限的路由纔會註冊到Vue實例中。考慮到每次刷新頁面的時候因爲vue的實例會丟失,而且角色的菜單也可能會更新,所以在每次加載頁面的時候作菜單的拉取和路由的注入是最合適的時機。所以核心是vue-router的addRoute
和導航鉤子beforeEach
兩個方法
vue-router3x
注
:3.5.0API也更新到了addRoute,注意區分版本變化
vue-router4x
我的更傾向於使用vue-router4x
的addRoute
方法,這樣能夠更精細的控制每個路由的的定位
大致思路爲,在beforeEach
該導航守衛中(即每次路由跳轉以前作判斷),若是已經受權過(authorized
),就直接進入next方法,若是沒有,則從後端拉取路由表註冊到實例中。(直接在入口文件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) } } })
因爲路由是動態註冊的,因此項目的初始路由就會很簡潔,只要提供靜態的不須要權限的基礎路由,其餘路由都是從服務器返回以後動態註冊進來的
// 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: '首頁' } }] } ] const router = createRouter({ history: createWebHistory(), routes, scrollBehavior() { // always scroll to top return { top: 0 } } }) export default router
其實只要遞歸拿到type爲MENU
的資源註冊到路由,過濾掉hidden:true
的菜單在左側樹顯示,此處再也不贅述。
RBAC 是基於角色的訪問控制(Role-Based Access Control )在 RBAC 中,權限與角色相關聯,用戶經過成爲適當角色的成員而獲得這些角色的權限。這就極大地簡化了權限的管理。這樣管理都是層級相互依賴的,權限賦予給角色,而把角色又賦予用戶,這樣的權限設計很清楚,管理起來很方便。
這樣登陸的時候只要獲取用戶
用戶選擇角色
角色綁定菜單
菜單
頁面緩存,聽起來可有可無的功能,卻能給客戶帶來極大的使用體驗的提高。
例如咱們有一個分頁列表,輸入某個查詢條件以後篩選出某一條數據,點開詳情以後跳轉到新的頁面,關閉詳情返回分頁列表的頁面,假如以前查詢的狀態不存在,用戶須要重複輸入查詢條件,這不只消耗用戶的耐心,也增長了服務器沒必要要的壓力。
所以,緩存控制在系統裏面頗有存在的價值,咱們知道vue
有keep-alive
組件可讓咱們很方便的進行緩存,那麼是否是咱們直接把根組件直接用keep-alive
包裝起來就行了呢?
實際上這樣作是不合適的,好比我有個用戶列表,打開小明和小紅的詳情頁都給他緩存起來,因爲緩存是寫入內存的,用戶使用系統久了以後必將致使系統愈來愈卡。而且相似於詳情頁這種數據應該是每次打開的時候都從接口獲取一次才能保證是最新的數據,將它也緩存起來自己就是不合適的。那麼按需緩存就是咱們系統迫切須要使用的,好在keep-alive
給咱們提供了include
這個api
注意這個include存的是頁面的name,不是路由的name
所以,如何定義頁面的name是很關鍵的
個人作法是,vue頁面的name值與當前的menu.json
的層級相連的name
(實際上通過處理就是註冊路由的時候的全路徑name)對應,參考動態導入的介紹,這樣作用兩個目的:
keep-alive
的include
選項是基於頁面的name
來緩存的,咱們使路由的name
和頁面的name
保持一致,這樣咱們一旦路由發生變化,咱們將全部路由的name
存到store
中,也就至關於存了頁面的name
到了store
中,這樣作緩存控制會很方便。固然頁面若是不須要緩存,能夠在menu.json
中給這個菜單noCache
設置爲true
,這也是咱們菜單表結構中該字段的由來。vue-devtools
進行調試,語義化的name
值方便進行調試。例如角色管理
對應的json位置
對應的vue文件
對應的vue-devtools
爲了更好的用戶體驗,咱們在系統裏面使用tag來記錄用戶以前點開的頁面的狀態。其實這也是一個hack
手段,無非是解決SPA
項目的一個痛點。
效果圖
大概思路就是監聽路由變化,把全部路由的相關信息存到store
中。根據該路由的noCache
字段顯示不一樣的小圖標,告訴用戶這個路由是不是帶有緩存的路由。
組件的封裝原則無非就是複用,可擴展。
咱們在最初封裝組件的時候不用追求過於完美,知足基礎的業務場景便可。後續根據需求變化再去慢慢完善組件。
若是是多人團隊的大型項目仍是建議使用Jest
作好單元測試配合storybook
生成組件文檔。
關於組件的封裝技巧,網上有不少詳細的教程,本人經驗有限,這裏就再也不討論。
基本框架搭建完畢,組件也封裝好了以後,剩下的就是碼業務功能了。
對於中後臺管理系統,業務部分大部分離不開CRUD
,咱們看到上面的截圖,相似用戶,角色等菜單,組成部分都大同小異,前端部分只要封裝好組件(列表,表單,彈框等),頁面均可以直接經過模板來生成。甚至如今有不少可視化配置工具(低代碼),我我的以爲目前不太適合專業前端,由於不少場景下頁面的組件都是基於業務封裝的,單純的把UI庫原生組件搬過來沒有意義。固然時間充足的話,能夠本身在項目上用node開發低代碼的工具。
這裏咱們能夠配合inquirer-directory來在控制檯選擇目錄
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) }
通常狀況下, 咱們和後臺定義好restful規範的接口以後,每當有新的業務頁面的時候,咱們要作兩件事情,一個是寫好接口配置,一個是寫頁面,這兩個咱們能夠經過模板來建立了。咱們使用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') // 斜槓轉駝峯 function toCamel(str) { return str.replace(/(.*)\/(\w)(.*)/g, function (_, $1, $2, $3) { return $1 + $2.toUpperCase() + $3 }) } // 選項框 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 } }
咱們來執行plop
經過inquirer-directory
,咱們能夠很方便的選擇系統目錄
輸入name名,通常對應後端的controller名稱
使用空格來選擇每一項,使用回車來確認
最終生成的文件
生成頁面的方式與此相似,我這邊也只是拋磚引玉,相信你們能把它玩出花來