项目设计相关面试题
- 分析:
- 需要前端统计的范围:
- 访问量(PV)统计:记录页面的访问次数,帮助了解网站的流量情况
- 自定义事件统计: 记录用户的具体行为,如按钮点击、表单提交等,以便分析用户行为
- 暴露 trackEvent 接口, 供开发者主动调用
- 性能:检测页面的加载时间、响应时间等性能指标,帮助有户用户体验
- 在页面加载完成后,使用 performance.timing 获取原始性能数据,并发送到服务器
- 错误:记录页面的错误信息,帮助快速定位和修复问题
- 通过监听 error 捕获JS错误
- 通过 监听 unhandedrejection 获取 未处理的Promise异常(如果Promise 加了catch 就不会捕获到错误信息了)
- 访问量(PV)统计:记录页面的访问次数,帮助了解网站的流量情况
- 数据上报
- 优先使用 navigator.sendBeacon
- 不会和主要业务代码抢占资源,而是在浏览器空闲时去做
- 并且在页面卸载时也能保证请求成功发送,不会阻塞页面的跳转
- 对不支持navigator.sendBeacon 的浏览器 使用 图片 的src 来兜底
- 优先使用 navigator.sendBeacon
- 需要前端统计的范围:
- 代码实现:
功能点:
- 基于RBAC权限模型 实现动态路由和菜单的权限控制
- 按钮级别权限控制
- axios拦截器统一处理http请求(token校验)
- 上传大文件
1.基于RBAC权限模型 实现动态路由和菜单的权限控制
- 基础架构设计:
- 采用RBAC权限模型,通过用户-角色-菜单 三级关系控制访问权限
- 保留静态路由,如登录页、404页。动态路由通过接口获取
- 使用vue-router 的 addRoute()方法动态注册路由
- RBAC核心思想
- 用户(User):系统的使用者
- 角色(Role):一组权限的集合,比如:管理员、超级管理员、编辑、访客等等
- 权限(Permission):具体的操作权限,比如:添加用户、删除操作、编辑操作
通过将用户与角色,角色与权限关联,实现权限管理
- 功能实现步骤
- 首先用户登录
- 登录成功后,获取用户权限信息,后端会返回角色和路由表:
{ menus:[ //菜单列表数据 { path:'/energry', name:"energy", component:"layout", meta:{'icon','energy',title:'正能量赞助'}, children:[ { name: "admanage_create" path:'admanage/create', component:"adm_enerygy_admanage_create", hidden: false, meta: {title: '添加投放'} }, { name: "admanage_detail" path:'admanage/detail', component:"adm_enerygy_admanage_details", hidden: true, // 不会在菜单栏中显示 meta: {title: '查看投放'} } ] } ],
roles:[] //角色列表 } - 根据用户角色获取动态路由表,然后通过router.addRoutes()动态添加路由
- 根据上一步获取的动态路由表加上静态的路由表,动渲染态菜单
- 具体代码实现:
//src/permission.js
import router from 'router' import { getAccessToken } from '@/utils/auth' import store from 'store' //路由全局前置守卫 router.beforeEach(async(to, from, next ) => { //在路由改变前执行 // next 是一个函数,调用时决定路由是否跳转 if(!getAccessToken()){ //令牌监测 const encodePath = encodeURIComponent(window.loaction.href) window.loaction.href = `https://xxx.com/login?redirect_uri=${encodePath}` //跳转认证中心去登录 }else{ if(store.getters.roles.length){// 判断当前用户是否已拉取完user_info信息 next() // 当有用户权限的时候,说明所有可访问的路由已经生成。没有权限的页面会进入404页面 }else{ // 调用用户信息,会返回 用户角色以及 动态路由表等等 const accessRoutes = await store.dispatch('user/getInfo') //vuex中实现 //动态添加可访问路由 router.addRoutes(accessRoutes) next({...to, replace: true}) //覆盖当前记录 } } })src/store/modules/user.js 将角色和路由保存vuex中
import {constantRoutes} from '@/router' // 静态路由表import { componentsMap } from '@/router/routermap'import {getInfo} from '@/api/account'// 设置对用的组件export function mapBackendRoute(routes) {routes.forEach(r => {if (r.component) {r.component = r.component === "Layout" ? Layout : componentsMap[r.component];}if (r.children && r.children.length > 0) {mapBackendRoute(r.children)}return routes})const state = { roles: [], routes: [] } const mutations = { SET_ROLE:(state, roles) =>{ state.roles = roles }, SET_ROUTES: (state, routes) =>{ state.addRoutes = routes, state.routes = constantRoutes.concat(routes) //菜单数据 } } const action = { getInfo({commit, state}){ return new Promise((resolve,rejected) =>{ getInfo().then(data =>{ const {roles, menus} = data commit('SET_ROLES', roles)
const accessedRoutes = mapBackendRoute(menus)commit('SET_ROUTES, accesssedRoted) }) }) } } export default { namespaced: true, state, mutations, actions }
根据前面,已经很容易能动态实现菜单渲染了:直接通过vuex 或取到routes 通过v-for进行渲染即可
2.按钮级别权限控制
- 方案一:用v-if 手动判断按钮是否有权限
- 方案二:封装一个指令权限
// src/plugins/permission.js import store from 'strore' function checkPermission(el, binding){ const { value } = bingding const roles = store.getters && store.getters.roles if(value && value instanceof Array){ if(value.length){ const hasPermission = roles.some(role => { return bingding.value.includes(role) }) } if(!hasPermission){ //没有权限将按钮删除 el.parentNode && el.parentNode.removeChild(el) } }else{ throw new Error('need roles like v-permission="['admin','editor']"') } } export default { inserted(el, binding){ checkPermission(el,binding) }, update(el, bingding){ checkPermission(el, bingding) } }
全局注册:
src/main.js import Vue from 'vue' import permission from './directive/permission.js' Vue.directive('permission',permission) //.....
在组件中使用:
<template> <el-tag v-permission="['admin']">添加用户</el-tag> <el-tag v-permission="['admin','editor']">编辑</el-tag> </template> - 方案三:封装成插件
- src/directive/promise
import store from 'strore' function checkPermission(el, binding){ const { value } = bingding const roles = store.getters && store.getters.roles if(value && value instanceof Array){ if(value.length){ const hasPermission = roles.some(role => { return bingding.value.includes(role) }) } if(!hasPermission){ //没有权限将按钮删除 el.parentNode && el.parentNode.removeChild(el) } }else{ throw new Error('need roles like v-permission="['admin','editor']"') } }
// 插件返回的是一个对象,所以对象中必须要有install 方法 export default { install (Vue){ Vue.directive('permission',{ inserted(el, binding){ checkPermission(el,binding) }, update(el, bingding){ checkPermission(el, bingding) } }) } }vue插件如果是一个对象,必须有一个install方法。如果是一个函数会被作为install方法。install()方法被调用时会将Vue作为参数传入。引用:
src/main.js import Vue from 'vue' import permission from './directive/permission.js' Vue.use(permission) //.....
执行Vue.use(permission) 时,默认会调用permission.install(Vue) 方法,病假Vue传入。install方法中注册了指令。
- src/directive/promise
3.axios拦截器统一处理http请求(token校验)
import axios from 'axios'; // 引入axios import { getToken, setToken} from '@/utils/auth' // 创建 Axios 实例 const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 50000 }) //请求拦截 service.interceptors.request.use(config => { const token = getToken() if(token){ config.headers['Authorization'] = `Bearer ${token}`; } return config }, error => { return Promise.error(error); }) //响应拦截器:处理 401异常 let isRefreshing = false // 刷新锁定 let requestsQueue = [] // 重试队列 service.interceptors.response.use( response => { return response.data // 直接返回数据体 }, async error => { const { config, response } = error // 处理401 Token 过期 if(response?.status === 401){ if(!isRefreshing){ isRefreshing = true try { // 调用刷新 Token 接口 const { data } = await axios.post('/refresh-token', { refreshToken: getToken('refresh_token') }) setToken(data.accessToken) // 更新 Token // 重放队列中的请求 requestsQueue.forEach(cb => cb()) } catch (e) { // 刷新失败跳转登录页 router.push('/login') return Promise.reject(e) } finally { isRefreshing = false requestsQueue = [] } } // 将当前请求加入队列并挂起 Promise return new Promise(resolve => { requestsQueue.push(() => resolve(service(config))) }) } return Promise.reject(error) })
4.上传大文件
- 技术方案:
- 文件切片上传
- 按固定大小(推荐5MB/片)动态计算分片总数
-
const chunks = Math.ceil(this.file.size / 5 * 1024 * 1024) // 5MB
-
- 使用Blob.slice()分割文件
const chunk = this.file.slice(i * this.CHUNK_SIZE, (i + 1) * this.CHUNK_SIZE)
- 使用SparkMD5 库计算哈希值,生成文件唯一指纹(用于断点续传)
- spark-md5是根据文件内容的二进制生成hash值的,所以同一个文件只要内容不变(与文件名不同、创建时间无关),hash值就不变
- 生成的hash如下:
const spark = new SparkMD5.ArrayBuffer() // 创建SparkMD5实例用于处理ArrayBuffer数据 const reader = new FileReader() // 创建FileReader实例 reader.readAsArrayBuffer(file) // 以ArrayBuffer的形式读取文件内容 reader.onload = e => { spark.append(e.target.result) // 将读取到的ArrayBuffer数据追加到SparkMD5实例中 const hash = spark.end() // spark.end() 计算最终的MD5值 }
- 通过FormData分片上传
const chunk = this.file.slice(i * this.CHUNK_SIZE, (i + 1) * this.CHUNK_SIZE) const formData = new FormData() formData.append('chunk', chunk) formData.append('hash', fileHash) formData.append('index', i) axios.post('/api/upload', formData,{
headers:{'Content-Type':'multipart/form-data'}
}) // 分片上传
- 按固定大小(推荐5MB/片)动态计算分片总数
- 并发控制
- 限制同时上传的分片数(3个),避免带宽过载
- 使用Promise.all 管理分片上传顺序
- 断点续传
- 通过localStorage 或服务器记录已上传分片哈希,中断后跳过已上传的分片
- 秒传优化
- 计算文件唯一哈希值(spart-md5),服务器存在相同文件时直接返回结果,实现秒传
- 文件切片上传
- 代码实现:
<template> <div id="app"> <input type="file" @change="handleFileChange" ref="fileInput" /> <button @click="uploadFile" :disabled="!file">开始上传</button> <progress :value="progress" max="100"></progress> </div> </template> <script> import axios from 'axios' import SparkMD5 from 'spark-md5' export default { data () { return { file: null, progress: 0, CHUNK_SIZE: 5 * 1024, // 5MB分片 MAX_CONCURRENT: 3 // 最大并发数 } }, methods: { handleFileChange (e) { this.file = e.target.files[0] // 获取选中的文件 }, // 计算文件MD5 async calculateHash (file) { return new Promise(resolve => { const spark = new SparkMD5.ArrayBuffer() // 创建SparkMD5实例用于处理ArrayBuffer数据 const reader = new FileReader() // 创建FileReader实例 reader.readAsArrayBuffer(file) // 以ArrayBuffer的形式读取文件内容 reader.onload = e => { spark.append(e.target.result) // 将读取到的ArrayBuffer数据追加到SparkMD5实例中 resolve(spark.end()) // spark.end() 计算最终的MD5值 } }) }, // 分片上传 async uploadFile () { if (!this.file) return // 如果没有文件被选中,则直接返回 const chunks = Math.ceil(this.file.size / this.CHUNK_SIZE) // 切片数量 const fileHash = await this.calculateHash(this.file) // 将文件生成唯一的hash (spart-md5) const uploadedChunks = JSON.parse(localStorage.getItem(fileHash) || '[]') // 根据fileHash 获取本地存储已上传的切片 // 上传队列 const uploadQueue = [] for (let i = 0; i < chunks; i++) { if (uploadedChunks.includes(i)) continue const chunk = this.file.slice(i * this.CHUNK_SIZE, (i + 1) * this.CHUNK_SIZE) const formData = new FormData() formData.append('chunk', chunk) formData.append('hash', fileHash) formData.append('index', i) // 将 上传的回调方法放到队列中,为了方便控制并发上传的数量 uploadQueue.push(() => axios.post('/api/upload', formData, { onUploadProgress: e => { this.progress = Math.round(((i * this.CHUNK_SIZE) + e.loaded) / this.file.size * 100) } }).then(() => { uploadedChunks.push(i) localStorage.setItem(fileHash, JSON.stringify(uploadedChunks)) }) ) } // 控制并发数 const parallelUpload = async (queue, max) => { console.log(queue, 'queue') const executing = [] for (const task of queue) { const p = task().then(() => { executing.splice(executing.indexOf(p), 1) }) executing.push(p) if (executing.length >= max) { await Promise.race(executing) } } await Promise.all(executing) } await parallelUpload(uploadQueue, this.MAX_CONCURRENT) } } } </script>
功能点:
- 大屏适配
1.大屏适配方案
- 分析:
- 做大屏项目,需要适配不同屏幕
- 利用transform 的 scale 属性缩放,缩放整个页面
- 使用transform-origin 属性将所放的中心点设置为左上角,以确保大屏幕按比例缩放
- 开发是按照设计稿尺寸 1920*1080来写,也就是16:9 的比例(这里可以替换成自己的分辨率)
- 不同屏幕宽高比例 和 设计稿 相比,有两种情况:
- 实际屏幕的宽高比> 16: 9时, 也就是宽度过宽,我们让高度全屏展示,左右留白
- 缩放比 以高度为基准,即 scale = 实际高度 / 设计稿高度(1080)
- 左右留白, left = (实际宽度 - 设计稿的宽度(1920) * scale) /2
- 实际屏幕的宽高比 < 16: 9时,也就是高度过高,我们让宽度铺满,高度上下留白
- 缩放比 以宽度为基准,即 scale = 实际宽度 / 设计稿宽度(1920)
- 上下留白, top = (实际高度 - 设计稿高度(1080) * scale) /2
- 实际屏幕的宽高比> 16: 9时, 也就是宽度过宽,我们让高度全屏展示,左右留白
- 实现:
<script setup lang='ts'> import { ref , onMounted, onBeforeUnMount} from 'vue' const mounted = ref<boolean>(true) const domRef = ref<HTMLElement>() const autoResize = () => { const {clientWidth, clientHeight} = document.body let designWidth = 1920 let designHeight = 1080 let designRatio = designWidth / designHeight let rotio = clientWidth / clientHeight let scale = 1 if(ratio > designRatio){ scale = clientHeight / designHeight top = 0 left = (clientWidth - designWidth * scale) / 2 }else { scale = clientWidth / designWidth left = 0 top = (clientHeight - designHeight * scale) /2 } if(mounted.value){ Object.assign(DomRef.style, { transform: `scale(${scale})`, left: `${left}px`, top: `${top}px` }) } } onMounted(() => { mounted.value = true autoResize() window.addEventListener('resize', autoResize) }) onBeforeUnmount( () => { mounted.value = false window.removeEventListener('resize', autoResize) }) </script> <template> <div class="container"> <div ref="domRef" class="inner"> <slot></slot> </div> </div> </template> <style lang='scss' scoped> .container{ width: 100vw; height: 100vh; .inner { transform-origin: left top overflow: hidden; } } </style>

浙公网安备 33010602011771号