项目设计相关面试题

1.开发一个前端 统计SDK, 你会如何设计?

2、后台管理系统(电商)

3.可视化大屏设计器-低代码平台

 

一、开发一个前端统计 SDK,你会如何设计?

  • 分析:
    • 需要前端统计的范围:
      • 访问量(PV)统计:记录页面的访问次数,帮助了解网站的流量情况
        •  
      • 自定义事件统计: 记录用户的具体行为,如按钮点击、表单提交等,以便分析用户行为 
        • 暴露 trackEvent 接口, 供开发者主动调用
      • 性能:检测页面的加载时间、响应时间等性能指标,帮助有户用户体验
        • 在页面加载完成后,使用 performance.timing 获取原始性能数据,并发送到服务器  
      • 错误:记录页面的错误信息,帮助快速定位和修复问题 
        • 通过监听 error  捕获JS错误
        • 通过 监听 unhandedrejection 获取 未处理的Promise异常(如果Promise 加了catch 就不会捕获到错误信息了)
    • 数据上报 
      • 优先使用 navigator.sendBeacon
        • 不会和主要业务代码抢占资源,而是在浏览器空闲时去做
        • 并且在页面卸载时也能保证请求成功发送,不会阻塞页面的跳转 
      • 对不支持navigator.sendBeacon 的浏览器 使用 图片 的src 来兜底
  • 代码实现:      

二、电商后台管理系统项目面试相关知识点

功能点:

  • 基于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方法中注册了指令。 

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'}
        }) // 分片上传
    • 并发控制
      • 限制同时上传的分片数(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   
  • 实现:
    <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>  

     

           
posted @ 2025-04-13 16:59  yangkangkang  阅读(12)  评论(0)    收藏  举报