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名称
使用空格来选择每一项,使用回车来确认
最终生成的文件
生成页面的方式与此相似,我这边也只是抛砖引玉,相信你们能把它玩出花来