基于 Nuxt.js 服务渲染框架搭建的后台管理系统,UI 框架选择的是Element UI
该后台管理系统只是一个极简的后台基础模板,只实现了用户登录和权限验证等功能。
项目搭建参考另一篇文章:nuxt-koa-mongodb
项目创建差异:
- 创建框架时,UI 框架选择Element UI
- 未进行 pwa 的额外配置
- 未进行 mongodb 数据库的相关配置
- 增加 sass/scss 配置
// 安装
npm node-sass sass-loader --save-dev
npm install @nuxtjs/style-resources
// 配置 nuxt.config.js
module.exports = {
css: [
// 配置全局css
'@/assets/css/main.scss'
],
modules: [
// 添加对应的模块
'@nuxtjs/style-resources'
],
styleResources: {
// 配置全局 scss 变量 和 mixin
// 注意:styleResources 配置的资源路径不能使用 ~ 和 @ ,需要使用相对或绝对路径。
scss: [
'./assets/style/variables.scss', // 全局 scss 变量
'./assets/style/mixins.scss' // 全局 scss 混合
]
}
}
- assets:未编译的静态资源
- components:组件,没有 asyncData 方法
- layouts:布局组件
- base.vue:基础框架页,不带任何组件的框架页
- default.vue:主要框架页,带侧边栏和顶栏的框架页
- Header/index.vue:顶栏组件
- layouts/Header/Breadcrumb.vue:面包屑组件
- layouts/Header/Dropdown.vue:下拉菜单组件
- Aside/index.vue:侧边栏组件
- layouts/Aside/AsideItem.vue:侧栏元组件
- layouts/Aside/Logo.vue:Logo 组件
- plugins/resizeHandler.js:移动端、PC 端适应配置
- middleware:中间件
- authorities.js:路由鉴权
- pages:页面,根据该目录下的 vue 文件自动生成对应的路由配置
- center/index.vue:用户中心
- log/index.vue:日志
- login/index.vue:登录
- system:系统管理
- pages/system.vue:嵌套路由父页
- auth.vue:权限管理
- role.vue:角色管理
- user.vue:用户管理
- others:其他页面
- 404.vue:404 页面
- 500.vue:500 页面
- index.vue:主页
- restful.vue:restful api 接口测试页
- plugins:自定义或第三方插件
- axios.js:@nuxtjs/axios 扩展配置
- element-ui.js:ElementUI 使用配置
- store:vuex 状态树配置
- server:服务端配置
- static:静态文件,不会被构建编译,直接映射至根目录下
- utils:公共 utils 函数
- utils.js:公用函数
- routes.js:自定义路由配置
- resizeHandler.js:PC 端、移动端响应式配置
- nuxt.config.js:nuxt.js 应用的个性化配置,可覆盖默认配置
- package.json:描述应用的依赖关系和对外暴露的脚本接口
用户身份验证通常有两种方式,一种是基于 cookie 的认证方式,另一种是基于 token 的认证方式。当前常见的无疑是基于 token 的认证方式。
token 是一个令牌,浏览器第一次访问服务端时,服务端会签发一张令牌。之后浏览器每次都要携带这张令牌进行访问,服务端就会认证该令牌是否有效,验证请求的合法性。一般令牌由用户信息、时间戳和由 hash 算法加密的签名构成,令牌中包含的用户信息还可以区分不同身份的用户。
- 客户端使用用户名和密码请求登录;
- 服务端收到请求,验证用户名和密码;
- 验证成功后,服务端会签发一个 token ,并把该 token 发送给客户端;
- 客户端收到 token 后把它存储起来,比如存在 Cookie 或 Local Storage 中;
- 客户端每次向服务端请求资源时,需要带着服务端签发的 token ;
- 服务端收到请求后,验证客户端请求中携带的 token(如 request 头部添加 Authorization ),如果验证成功,就向客户端返回请求的数据,如果不成功返回 401 错误码,鉴权失败。
- 优点:无状态机制,在此基础上,可以实现天然的跨域和前后端分离等。
- 缺点:服务器每次都需要对其进行验证,会产生额外的运行压力。此外,无状态的 api 缺乏对用户流程或异常的控制,为了避免一些例如回放攻击的异常情况,大多会设置较短的过期时间。
JWT 是一个开放标准(RFC 7519),它定义了一种简洁的、自包含的方法,用于通信双方之间以 JSON 对象的形式安全地传输信息。该信息可以被验证和信任,因为它是数字签名的,JWT 可以使用 HMAC 算法或者是 RSA 的公钥密钥进行签名。
实战逻辑:
- 1.服务端生成 token,在登录路由中进行验证,可携带用户名等必要信息,并将其放至上下文对象中。
// 服务端生成token,详见 server/routes/user.js
const jwt = require("jsonwebtoken"); // 用于签发、解析`token`
const secret = "secret"; // jwt密钥
// 用户登录
router.post("/login", ctx => {
const { username, password } = ctx.request.body;
// jsonwebtoken在服务端生成token返回给客户端
const token = jwt.sign({ username, password }, secret, { expiresIn: "2h" });
ctx.body = {
code: 0,
data: {
token
},
msg: "登录成功"
};
});
- 2.客户端登录成功并获取 token 信息后,将其保存在客户端中。如 localstorage,cookie 等。
// 客户端存储token信息,详见 store/index.js
login ({ commit }, userInfo) {
const { username, password } = userInfo
return new Promise((resolve, reject) => {
login({ username, password }).then((response) => {
const { data } = response
if (data.code === 0) {
commit('setToken', data.data.token)
setToken(data.data.token)
}
resolve(data)
})
})
}
-
3.在请求服务器端 API 接口时,需要设置 authorization,把 token 带在请求头中传给服务器进行验证。如下两种方式(本项目采用的是第一种方式):
(1) 利用 axios 请求拦截器,设置请求头,将 token 放到 headers 中;
(2) 利用 koa 的中间件在总路由中进行拦截处理。
// a. axios请求拦截器,详见 plugin/axios.js
$axios.onRequest(config => {
const token = getToken();
config.headers.common.Authorization = "Bearer " + token;
return config;
});
// b. koa中间件拦截,放在 server/index.js
app.use(bodyParser());
app.use(async (ctx, next) => {
let params = Object.assign({}, ctx.request.query, ctx.request.body);
ctx.request.header = { authorization: "Bearer " + (params.token || "") };
await next();
});
-
1.koa-jwt 中间件的验证方式有三种:
- 在请求头中设置 authorization 为 Bearer + token,注意 Bearer 后有空格。(koa-jwt 的默认验证方式 {'authorization': "Bearer " + token})
- 自定义 getToken 方法
- 利用 Cookie(此 cookie 非彼 cookie)此处的 Cookie 只作为存储介质发给服务端的区域,校验并不依赖于服务端的 session 机制,服务端不会进行任何状态的保存。
-
2.前端发送请求携带 token ,服务端收到请求后,需进行如下处理:
- token 是否正确,不正确则返回错误
- token 是否过期,过期则刷新 token 或返回 401 表示需要重新登录
// 详见server/index.js
const koaJwt = require("koa-jwt"); // 用于路由权限控制
// 错误处理:当token验证异常时的处理,如token过期、token错误
app.use((ctx, next) => {
return next().catch(err => {
if (err.status === 401) {
ctx.status = 401;
ctx.body = {
code: 20001,
msg: err.originalError ? err.originalError.message : err.message
};
} else {
throw err;
}
});
});
// 路由权限控制:控制哪些路由需要jwt验证,哪些接口不需要验证。除了path里的路径不需要验证token,其他都要。
app.use(
koaJwt({
secret: "secret"
}).unless({
path: [/^\/login/, /^\/register/]
})
);
- 3.服务端刷新 token 后,前端需要同步更新 token :服务端更新的 token 是在响应头里,所以前端需要在响应拦截器中获取新 token 。
// 响应拦截器
$axios.onResponse(resp => {
// 获取更新的token
const { authorization } = resp.headers;
// 如果token存在,则存在cookie中
authorization && setToken(authorization);
return Promise.resolve(resp.data);
});
// utils/routes.js
const menus = [
{
name: "login",
path: "/login",
meta: { requireAuth: false }
},
{
name: "center",
path: "/center",
meta: { title: "个人中心", icon: "el-icon-user", hidden: false }
},
{
name: "system",
path: "/system",
meta: { title: "系统管理", icon: "el-icon-setting", hidden: false },
children: [
{
name: "system-user",
path: "user",
meta: { title: "用户管理", icon: "el-icon-headset", hidden: false }
},
{
name: "system-role",
path: "role",
meta: { title: "角色管理", icon: "el-icon-monitor", hidden: false }
}
]
}
];
const iterator = (list, menus) => {
const defaultMeta = {
hidden: true,
requireAuth: true
};
for (const item in list) {
for (const m in menus) {
if (
list[item].name === menus[m].name &&
list[item].path === menus[m].path
) {
list[item].meta = Object.assign({}, defaultMeta, menus[m].meta || {});
if (list[item].children && list[item].children.length > 0) {
iterator(list[item].children, menus[m].children);
}
}
}
}
return list;
};
module.exports = (routes, resolve) => {
routes = iterator(routes, menus);
};
// middleware/authorities.js
import { getToken, getTokenInServer } from "~/utils/utils.js";
export default function({ req, route, redirect }) {
const isLogin = route.name && route.name.indexOf("login") === 0; // 登录页不需验证
const isAuth = route.meta.some(record => record.requireAuth); // 是否需要强制登录
const path = route.fullPath.split("?")[1]
? "?" + route.fullPath.split("?")[1]
: "";
const redirectURL = "/login" + path;
const token = process.server ? getTokenInServer(req) : getToken();
if (process.server) {
// 服务端渲染
if (!isLogin && isAuth && !token) {
return redirect(redirectURL);
}
}
if (process.client) {
// 客户端渲染
if (!isLogin && isAuth && !token) {
return redirect(redirectURL);
}
}
}
使用 import 引入文件报错:import routes from './utils/routes' SyntaxError: Unexpected identifier
解决 import 和 export 不能用的问题:node 版本 9 以上就已经支持了,但是需要把文件名改成*.mjs,并且加上--experimental-modules 选项。此项目使用的方法是换成 require/export
- 模块导入导出有哪些方式:
- 模块导入方式有:require、import、import xxx from yyy、import {xx} from yyy
- 模块导出方式有:exports、module.exports、export、export.default
- 使用规则和范围:
- 模块导入方面
- require: node 和 ES6 都支持的模块导入方式
- import 和 import xxx from yyy 和 import {xx} from yyy:只有 ES6 支持
- 模块导出方面
- module.exports/exports: node 本身支持的模块导出方式
- export/import: 只有 ES6 支持的模块导出方式
- 模块导入方面
- CommonJS 规范(node 中模块的导入导出)
- 由于之前 js 没有很统一比较混乱,代码按照各自的喜好写并没有一个模块的概念,而这个规范说白了就是对模块的定义:
- CommonJS 定义模块分为:模块标识(module)、模块定义(exports)、模块引用(require)
// 错误写法(import/export)
export default (routes, resolve) => {
routes = iterator(routes, menus);
};
import routes from "./utils/routes";
// 正确写法(require/export)
module.exports = (routes, resolve) => {
routes = iterator(routes, menus);
};
const routes = require("./utils/routes.js");
- 验证不同生命周期,相关判断条件的值
测试方式:console.log 输出日志
// store/app.js
import { getLocalCache } from '~/utils/utils.js'
sidebar: {
opened: getLocalCache(sideBarFlag, 'vuex') ? !!+getLocalCache(sideBarFlag, 'vuex') : true,
withoutAnimation: false
}
// utils/utils.js
export function getLocalCache (name, type) {
console.log(Cookies.get(name) + '...' + name + '...' + type)
return Cookies.get(name)
}
// layouts/default.vue
// 备注:所有用到这三个值的地方,都注释掉,否则会出现多余的日志语句。如v-if="device==='mobile'会触发device的computed,会多打印出一条日志。多次用到某值时,服务端会打印出多条,客户端只打印了一条。
export default {
computed: {
fixedHeader () {
console.log('computed...fixedHeader...' + this.$store.state.settings.fixedHeader)
return this.$store.state.settings.fixedHeader
},
device () {
console.log('computed...device...' + this.$store.state.app.device)
return this.$store.state.app.device
},
sidebar () {
console.log('computed...sidebar...' + JSON.stringify(this.$store.state.app.sidebar))
return this.$store.state.app.sidebar
}
},
created () {
console.log('created...sidebar...' + JSON.stringify(this.sidebar))
console.log('created...device...' + this.device)
console.log('created...fixedHeader...' + this.fixedHeader)
},
mounted () {
console.log('mounted...sidebar...' + JSON.stringify(this.sidebar))
console.log('mounted...device...' + this.device)
console.log('mounted...fixedHeader...' + this.fixedHeader)
},
}
测试结果:左侧为服务端终端打印日志,右侧为浏览器控制端打印日志。
测试结论如下:
- vuex初始化、 computed、created阶段在服务端和客户端均会运行;asyncData、fecth仅在服务端运行;mounted开始之后,仅在客户端运行
- vuex初始化获取cookie,服务端渲染时无window对象,故此值为undefined;客户端有beforeMount阶段前没有,beforeMount开始有window对象,可正确获取到值
- asyncData、fetch仅在页面组件有效,在default.vue中无效
- 生命周期先后顺序:vuex(store) -> asyncData(数据合并到data) -> fetch(数据同步到store) -> computed -> created -> mounted
- 客户端aside_status打印两次,是因为aside_status有值,getLocalCache(sideBarFlag, 'vuex')为true,执行了两遍
- 分析问题原因
- 分析resizeHandler.js:mounted()阶段会在页面刷新时触发,页面进来的一瞬间isMobile为false,mounted()完后才变成true,这会导致侧栏组件显示和隐藏都慢半拍。default.vue的computed(classObj)在mounted之前就执行了,所以是以false来赋值的,之后才变为true,导致侧栏会闪现一下。
- store.app.sidebar初始化时,总是一个值,判断条件无效
// store/app.js
export const state = () => {
return {
sidebar: {
// 每次刷新页面,都相当于打开服务端渲染的第一个页面,服务端运行到该处时,getLocalCache(sideBarFlag, 'vuex')为undefined,所以该三元判断的结果一直为true,每次刷新页面PC端侧栏都是展开的状态,H5端都是先展开后隐藏的状态。不刷新页面,直接路由跳转是没问题的。
opened: getLocalCache(sideBarFlag, 'vuex') ? !!+getLocalCache(sideBarFlag, 'vuex') : true,
withoutAnimation: false
}
}
}
- 尝试解决问题
- store.app.sidebar固定默认值,opened为false。
- default.vue页面sidebar()不放在computed阶段。
- 改造resizeHandler.js:经过测试,beforeMount()在computed()之前运行,而mounted()在computed之后运行,故此可将isMobile的判断放在beforeMount(),而不是mounted()。
// resizeHandler.js 改造前
beforeMount () {
window.addEventListener('resize', this.$_resizeHandler)
},
mounted () { // 刷新页面时触发
const isMobile = this.$_isMobile()
console.log('_resizeHandler...mounted...' + isMobile)
this.$store.dispatch('app/toggleMobile', isMobile)
if (isMobile) {
this.$store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
},
// resizeHandler.js 改造后
beforeMount () {
window.addEventListener('resize', this.$_resizeHandler)
// 刷新页面时触发
const isMobile = this.$_isMobile()
console.log('_resizeHandler...beforeMount...' + isMobile)
this.$store.dispatch('app/toggleMobile', isMobile)
if (isMobile) {
this.$store.dispatch('app/closeSideBar', { withoutAnimation: true })
} else {
this.$store.dispatch('app/setSideBar')
}
}
- 上述解决后,当opened为false时刷新页面,会产生新的问题:
The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render
(客户端渲染的虚拟DOM树与服务器渲染的内容不匹配。这可能是由于错误的HTML标记(例如,在<p>
内嵌套块级元素或缺少<tbody>
)引起的。保证和执行完整的客户端渲染。)- 经排查,是AsideItem组件引起的报错
- 原因:上述步骤,beforeMount阶段改变了store的值,导致服务端和客户端computed阶段获取的值不匹配。