业务方面
菜单管理

7.0系统的菜单只有两级(第二级可以拥有子页面):
一级(比如http://localhost:3000/#/index)一定是菜单不是有效路由。会被重定向到二级路由(http://localhost:3000/#/index/home)

二级路由才是有效路由。

点击详情会进入所谓的三级路由(其实就是二级路由,在路由里注册了,菜单视图里却没有注册)

菜单管理二级路由的操作栏没有添加子路由按钮是怎么实现的?

javascript
const actions = [
...,
{
label: t('list.添加子路由'),
sign: row =>
hasPermission('sys:route:add') && row.pcodes.split(',').length < 3,
click: add
}
...
]
这里的sign传的不是布尔值而是函数,而且这个函数还接受了一个参数。回到组件库看看:
html
<el-table-column
v-if="actions.filter(rr => !!rr.sign).length > 0"
:label="t('message.操作')"
<template #default="scope">
<el-button v-for="(info, index) in getActions(scope.row)" :key="index">
{{ info?.label || '' }}
</el-button>
</template>
<el-table-column/>
javascript
const getActions = (row: any) =>
props.actions.filter(t =>
typeof t.sign === 'function' ? t.sign(row) : t.sign
);
按钮权限

根据路由分类按钮权限。在登录后会通过接口获取当前用户的所有权限集,并存储在状态管理器里。
javascript
import { usePermissionStoreHook } from '@/store/modules/router';
const permissionStore = usePermissionStoreHook();
export function hasPermission(key: string) {
const jurisdictionArr: Array<string> = permissionStore.appPermission;
if (jurisdictionArr && jurisdictionArr.length) {
return (
jurisdictionArr.indexOf(key) > -1 || jurisdictionArr.indexOf('all') > -1
);
} else {
// 无权限
return false;
}
}
组织管理


组织拥有多层结构。绑定资源和用户概念。
用户、角色、查看范围、权限、路由
概念:
- 一个组织下有许多用户(用户只能有一个组织)。
- 用户的查看范围不止它的所属组织(可以查看多个组织)。
- 用户可以绑定多个角色。
- 每种角色可以绑定按钮权限和路由。

角色:

框架亮点
微内核+插件化架构设计

怎么理解组件库组件提供了统一的设计语言和交互模式

架构设计有几种模式
Web 开发 7 年,八千字浅谈前端架构设计与工程化结合自己多年的 Web 开发实际项目经验,分享一些我对前端架构设计、 - 掘金
前端设计模式有哪几种
盘点前端开发中最常见的几种设计模式设计模式介绍 设计模式是开发的过程中,遇到一些问题时的解决方案,这些方案是通过大量试验 - 掘金
项目中涉及到的几种前端设计模式举例
单例模式
单例模式的作用
资源管理 : 确保关键资源只有一个实例
配置统一 : 避免重复配置
内存优化: 减少不必要的对象创建

工厂模式
动态创建 : 根据参数动态创建对象
解耦 : 客户端不需要知道具体的创建过程
扩展性 : 易于添加新的产品类型
工厂模式(Factory Pattern) 是一种创建型设计模式,它提供了一种创建对象的最佳方式。工厂模式的核心思想是:
不直接使用 new 操作符创建对象
通过工厂方法来创建对象
将对象的创建逻辑封装起来


简单来说就是用户只需要传入配置,就能享受到工厂根据配置在内部加工后产出的产品了,用户看不到工厂内部做了什么,也不关心它做了什么。
装饰器模式
装饰器模式的作用
功能扩展 : 在不修改原有代码的基础上添加新功能
职责分离 : 将不同的功能分离到不同的装饰器中
动态组合: 可以动态地组合不同的装饰器
应用层和组件库的国际化处理方案
组件库和应用库的国际化分开管理
分包自己维护一套i18n
平台的语言包状态生效后,调用组件库里提供的方法切换语言包
切换分包里的i18n状态就行了
颜色主题无缝切换
业务包:修改css变量

组件库:
无需改动,用的也是跟主包一样的css变量名。到时引入后会继承docuemnt最顶层也就是主包里的css变量值

脚本工具梳理
mac权限

初始化注入husky
bash
"prepare": "husky install"

确保在每一次安装依赖后husky都能被正常安装

bash
"postinstall": "npm run prepare && cd script && node prepareHusky"
prepare会被执行两次(一次是pnpm install一次是husky install),但是没办法,prepare的顺序在postinstall之后。我必须确保husky已经安装上。
把自定义的检查文件移动到.husky文件夹中:


实际流程:


引用本地npm组件
bash
"localBase": "pnpm uninstall yzy-base && cd script && node localBase",

语言包差异比较
找到两个语言包的地址并解析文件内容,比较差异
javascript
const zhCnPath = path.join(path.resolve('..'), 'src/lang/package/zh-cn.ts');
const enPath = path.join(path.resolve('..'), 'src/lang/package/en.ts');
// 把文件里面的键值对导出到node.js里来(由文件变成实际数据)
const zhCnData = parseLangFile(zhCnPath);
const enData = parseLangFile(enPath);
function parseLangFile(filePath) {
// 使用 UTF-8 编码同步读取指定路径的文件内容
const content = fs.readFileSync(filePath, 'utf8');
// (['"]?) - 第一个捕获组:匹配可选的引号(单引号或双引号)
// ([^'":\s]+) - 第二个捕获组:匹配键名,不能包含引号、冒号或空格
// \1 - 反向引用:确保键名两边的引号类型一致
// \s*:\s* - 匹配冒号前后的可选空格
// ['"]([^'"]*)['"] - 第三个捕获组:匹配被引号包围的值
const regex = /(['"]?)([^'":\s]+)\1\s*:\s*['"]([^'"]*)['"]/g;
let match;
while ((match = regex.exec(content)) !== null) {
const key = match[2];
const value = match[3];
if (key && value) {
keyValuePairs[key] = value;
}
}
return keyValuePairs;
}
console.log(`zh-cn.ts 文件包含 ${Object.keys(zhCnData).length} 个键值对`);
console.log(`en.ts 文件包含 ${Object.keys(enData).length} 个键值对\n`);
设置基准对象(一般就是中文包,判断中文包里的键名英文包里是否拥有),进行比较
javascript
// 找出缺少的键
const missingKeys = findMissingKeys(zhCnData, enData);
// 比较两个对象,找出缺少的键
function findMissingKeys(baseObj, compareObj) {
const missingKeys = [];
for (const key in baseObj) {
if (!(key in compareObj)) {
missingKeys.push({
key: key,
value: baseObj[key]
});
}
}
return missingKeys;
}
判断missingKeys长度是否大于0,大于0输出这个键值对就行了
如果想输出为文本形式,则拼接为文本字符串,并输出:
javascript
const outputPath = path.join(__dirname, 'logs/missing-keys.txt');
const outputContent = missingKeys
.map(item => `'${item.key}': '${item.value}',`)
.join('\n');
fs.writeFileSync(outputPath, outputContent, 'utf8')
Websocket相关
心跳

当链接建立或收到消息后,3秒后发ping,6秒后检查连接状态。如果在3-6秒之间又收到消息(pong或ws消息),就会取消检查连接状态,并开始重复上面操作。如果在3秒内收到,就会取消ping发送和连接状态。如果没有收到,就会主动检查ws.readyState(实时的,但有延迟)。如果没有异常,则继续执行心跳。
javascript
// 在链接成功和收到消息时执行心跳检查
class Ws {
...
ws.onopen = e => {
...
this.heartCheck();
}
// 消息接收
ws.onmessage = e => {
// 心跳
this.heartCheck();
// 回调
this.onmessage(e);
};
// 心跳检查
private heartCheck() {
// 清除心跳、检查连接接定时器
this.h_timer && clearTimeout(this.h_timer);
this.c_timer && clearTimeout(this.c_timer);
//
this.h_timer = setTimeout(() => {
// 发送ping
(this.ws as WebSocket).send('ping');
// 检查连接状态定时器,确认连接状态
this.c_timer = setTimeout(() => {
// 连接失败,进行关闭
if ((this.ws as WebSocket).readyState !== 1) {
this.close();
}else {
// 心跳
this.heartCheck();
}
}, 3000);
}, 3000);
}
...
}
HTTP拦截器
在axios提供的请求/响应拦截器注入系统的逻辑就好了。
初始化请求对象:

请求拦截器:
响应拦截器:

错误处理(一般是响应状态码不对),接上面的error函数

路由鉴权
路由守卫设计
javascript
import NProgress from 'nprogress'; //进度条插件
import 'nprogress/nprogress.css';
// 白名单路由 不需要登录即可访问
const whiteList = ['/login'];
router.beforeEach(async (to, from, next) => {
// 进度条启动
NProgress.start();
const hasToken = sessionStorage.getItem('accessToken');
// 没有token,则没有登录
if (!hasToken) {
// 未登录可以访问白名单页面
if (whiteList.indexOf(to.path) >= 0) {
next();
} else {
// redirect是登录后顺着原路跳转回去的路径
next(`/login?redirect=${to.path === '/login' ? from.path : to.path}`);
NProgress.done();
}
} else {
// 查看权限路由是否已经生成。如果生成了则放行
if (permissionStore.routes.length === 0) {
// 路由鉴权核心逻辑:生成账号权限路由
const accessRoutes = await permissionStore.generateRoutes();
accessRoutes?.forEach((route: any) => {
// router看下面
router.addRoute(route);
});
next({ ...to, replace: true });
} else {
next();
}
NProgress.done();
}
})
为什么判断有权限路由就给放行呢?因为一旦是跳转的页面已经注册了路由,那么可以正常跳转。如果没有注册,则会统一跳到404页面去,所以不需要额外的判断
路由初始化注册
沿用了路由懒加载(vite会根据动态引入进行分chunk)
javascript
// hash模式
import { createRouter, createWebHashHistory } from 'vue-router';
/** @name 创建路由 */
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes as RouteRecordRaw[],
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 })
});
export const constantRoutes = [
{
path: '/login',
component: () => import('@/views/login/index.vue'),
name: 'Login',
meta: { hidden: true }
},
// 404
{
path: '/:pathMatch(.*)',
component: () => import('@/views/error/404.vue')
},
// 初始入口'/'重定向为'/index'
{
path: '/',
component: Layout,
redirect: '/index',
children: [
{
path: '/index/task_Center',
component: () => import('@/views/task_Center/index.vue'),
name: 'task_Center',
meta: {
keepAlive: false,
title: t('list.任务中心')
}
}
]
},
]
export default router;
// main.ts
app.use(router)
上面是初始化router,只有三个。生成权限路由后会通过router.addRoute(route)挂载到路由里去。
生成权限路由
原理是将后端返回的路由与本地的路由表比对后生成route格式的路由进行挂载。
javascript
const accessRoutes = await permissionStore.generateRoutes();
accessRoutes?.forEach((route: any) => {
router.addRoute(route);
});
面包屑
面包学就是红色框,显示了当前页面标签和历史页面标签。可以通过切换或关闭进行控制。

layout

这是页面骨架。这个系统只有二级路由才是有效路由(结构:一级路由/二级路由)

面包屑实现

html
<q-tags-view
:routers="visitedViews" // 面包屑列表
:route="route" // 当前面包屑
@change="changeView" // 切换面包屑
/>
业务组件只是个视图容器而已。我们关注visitedViews、route、changeView怎么生成就好了。
visitedViews
在状态管理器里维护,在路由守卫里触发新增

javascript
// store
const visitedViews = ref<TagView[]>([]);
function addVisitedView(view: TagView) {
// 没有标题无法正常显示
if (!view.meta || !view.meta.title) return;
// 已经有重复路由了
if (visitedViews.value.some(v => v.path === view.path)) {
replaceVisitedViewQuery(view);
return;
}
visitedViews.value.push(view)
}
// 处理重复路由
function replaceVisitedViewQuery(view: TagView) {
const viewInfo = visitedViews.value.find(v => v.path === view.path);
if (viewInfo && viewInfo.query !== view.query) {
viewInfo.query = view.query;
}
}
route
javascript
import { useRoute } from 'vue-router';
const route: any = useRoute();
// 组件库判断当前路径
function isActive(tag: Router) {
return tag.path === props.route?.path;
}
changeView
这个方法监听了视图组件库的一系列变化:点击、关闭当前面包屑、关闭其他面包屑
javascript
const changeView = (tag: any) => {
const index = visitedViews.value.findIndex(
(v: { path: string }) => v.path === tag.router.path
);
// 发生操作后用户应该跳转到哪个页面
// ① 如果不是最后一个,那么无论是发生点击还是删除,用户下一个目标页一定是index
// ② 如果是最后一个,那么如果是点击就不变,删除就是往前推一个
const currIndex =
index < visitedViews.value.length - 1
? index
: tag.type === 'click'
? index
: visitedViews.value.length - 2;
}

页面缓存
html
<!-- layout.vue -->
<!-- 精简写法 -->
<el-main class="main" id="v7main">
<router-view v-slot="{ Component }">
<keep-alive :include="caches">
<component :is="Component" />
</keep-alive>
</router-view>
</el-main>
<!-- 完整写法 -->
<router-view v-slot="slotProps">
<keep-alive :include="caches">
<component :is="slotProps.Component" />
</keep-alive>
</router-view>
<!-- 更完整写法 -->
<router-view>
<template v-slot:default="slotProps">
<keep-alive :include="caches">
<component :is="slotProps.Component" />
</keep-alive>
</template>
</router-view>
当使用 v-slot 时,如果不指定插槽名称,Vue 会将其视为默认插槽。对于默认插槽,可以直接在组件标签上使用 v-slot,而不需要包装在 <template> 中。
javascript
// cacheHook.ts
import { useRoute } from 'vue-router';
// 一级路由不计入缓存
const ignoreGathers = ['base-layout'];
const caches = ref<string[]>([]);
export default function useRouteCache() {
const route = useRoute();
function gatherCaches() {
watch(() => route.path, storeRouteCaches, {
immediate: true
});
}
// 对当前路由进行缓存
function storeRouteCaches() {
// route.matched会包含从根路由到当前路由的所有匹配的路由记
// 例如:[
// { path: '/user', component: UserLayout, meta: { keepAlive: true } },
// { path: '/user/profile', component: ProfileLayout, meta: { keepAlive: false } },
// { path: '/user/profile/settings', component: SettingsPage, meta: { keepAlive: true }}]
route.matched.forEach(routeMatch => {
const componentDef: any = routeMatch.components?.default;
const componentName = componentDef?.name || componentDef?.__name;
if (ignoreGathers.includes(componentName)) return;
// 配置了meta.keepAlive的路由组件添加到缓存
if (routeMatch.meta.keepAlive) {
if (!componentName) {
// eslint-disable-next-line no-console
console.warn(`${routeMatch.path} 路由的组件名称name为空`);
return;
}
caches.value.push(componentName as string);
}
})
}
}
// APP.vue 一级路由是router-view
<template>
<ElConfigProvider ref="el" :locale="appStore.locale" :size="appStore.size">
<router-view />
</ElConfigProvider>
</template>
<script lang="ts" setup>
...
const { gatherCaches } = useRouteCache();
// 开始缓存收集
gatherCaches();
</script>
TS global全局声明

/src/types/global.d.ts
声明在系统中全局类型接口,用于业务逻辑


/src/vite-env.d.ts
声明Vite环境 和模块 类型,用于构建工具集成
vite是运行在node环境下的,它遵循tsconfig.node.json里的配置。这里面要求ts在node环境下读取
vite-env.d.ts的声明。

TypeScript
declare module 'element-plus';
declare module 'yzy-base';

系统打包
应用层的打包,依旧是build属性配置:
TypeScript
target: 'es2015',
outDir: 'dist',
这块在组件库的打包里讲过。
bash
sourcemap: env.BUILD_SOURCEMAP === 'true',
根据环境变量配置。
sourcemap


生成js.map文件


.map文件包含了从压缩代码到原始源代码的映射关系(主要是mappings属性,包括了原始文件和编译文件的行列对应关系)。

举例:
开启了sourceMap:
浏览器控制台还原"源映射"后:


关闭sourcemap后浏览器显示:、


H5平台
针对首屏加载,采用路由懒加载+KeepAlive+ManualChunks方案

KeepAlive跟上面web平台一样,通过路由元信息判断,这里不讲了。
ManualChunks

javascript
// vite.config.ts
import { configManualChunk } from './config/vite/optimizer'
...
rollupOptions: {
output: {
manualChunks: configManualChunk,
},
},
这个实现起来比较复杂,可以看面试篇
javascript
// ./config/vite/optimizer
// 分包策略执行
export const configManualChunk = (id: string) => {
if (/[\\/]node_modules[\\/]/.test(id)) {
const matchItem = vendorLibs.find((item) => {
const reg = new RegExp(`[\\/]node_modules[\\/]_?(${item.match.join('|')})(.*)`, 'ig')
return reg.test(id)
})
return matchItem ? matchItem.output : null
}
}
// 分包策略
const vendorLibs: { match: string[]; output: string }[] = [
// Vue 核心库(必须优先加载)
{
match: ['vue', 'vue-router', 'pinia'],
output: 'vue-vendor'
},
// UI 组件库(体积较大)
{
match: ['vant'],
output: 'vant-vendor'
},
// 地图相关(按需加载)
{
match: ['@amap'],
output: 'amap-vendor'
},
// 工具库
{
match: ['axios', 'dayjs', 'lodash'],
output: 'utils-vendor'
},
// 图表库(体积大,且不是首屏必需)
{
match: ['echarts'],
output: 'echarts-vendor'
}
]

基于postcss-pxtorem完成PX到REM的自动转换,动态设置根字体
html
<meta
name="viewport"
content="width=device-width,
initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
/>
width=device-width:视口宽度等于设备宽度
initial-scale=1:初始缩放比例为 1
maximum-scale=1, minimum-scale=1:禁止缩放
user-scalable=no:禁止用户手动缩放


PostCSS
在开发阶段和生产构建阶段皆参与转换。

开发阶段:

构建阶段:

postcss-pxtorem:用于移动端响应式适配的 PostCSS 插件配置,它会自动将 CSS 中的 px 单位转换为 rem 单位,实现不同屏幕尺寸的自适应布局。
为什么移动端上rem单位比px单位好呢?


原生H5的移动端项目是否可以直接用rem呢?
可以,但是需要手动设置基准值(root元素的font-size)。
设计思想
问题的本质:一套代码适配所有屏幕




项目里对移动端响应式适配的处理
通过 postcss-pxtorem(构建时自动转 px 到 rem) + setRootFontSize(运行时动态设置基准值) 两部分配合
setDomFontSize:根据上面的思想,对设备宽度进行十等分,一个rem等于设备宽度/10
javascript
// main.ts
const setDomFontSize = (): void => {
const width = document.documentElement.clientWidth
|| document.body.clientWidth
// 如果屏幕375宽 那么fontSize是37.5 则rem * 37.5 = px与设计图刚好契合
// 如果屏幕是750宽,那么fontSize是75 则rem * 75 = 2倍px 刚好对设计图放大了两倍
const fontsize = setDomFontSize / 10 + 'px'
document.getElementsByTagName('html')[0].style.fontSize = fontsize
}
setDomFontSize()
const setDomFontSizeDebounce = _.debounce(setDomFontSize, 400)
window.addEventListener('resize', setDomFontSizeDebounce)
postcss配置:
核心配置rootValue,设计稿是750,则750/10,设计稿是300,则300/10
javascript
// postcss.config.js
'postcss-pxtorem': {
rootValue: 75, // 基于750设计稿,75 = 750/10
unitPrecision: 6, // 保留6位小数,确保精度
propList: ['*'], // 所有属性都转换
selectorBlackList: [ // 不转换的选择器
'.no-rem',
'van-', // Vant组件不转换
'el-', // Element Plus不转换(如果有)
],
replace: true, // 直接替换,不保留px
mediaQuery: true, // 媒体查询中的px也转换
minPixelValue: 1, // 最小转换值,1px也转换
exclude: /node_modules/i, // 排除第三方库
},
autoprefixer
javascript
// postcss.config.js
module.exports = {
plugins: {
autoprefixer: {
overrideBrowserslist: [
'Android 4.1',
'iOS 7.1',
'Chrome > 31',
'not ie <= 11', //不考虑IE浏览器
'ff >= 30', //仅新版本用"ff>=30
'> 1%', // 全球统计有超过1%的使用率使用">1%";
'last 2 versions', // 所有主流浏览器最近2个版本
],
grid: true, // 开启grid布局的兼容(浏览器IE除外其他都能兼容grid,可以关闭开启)
}
}
转成浏览器适配代码
零拷贝、自动同步的canvas离屏渲染
webworker
canvas的离屏渲染将在worker线程中执行
ts类型声明

javascript
import dvsWaterfallCanvasWorker from '/@/aiot/worker/waterfallCanvas?worker&inline'
worker.value = new dvsWaterfallCanvasWorker()
离屏canvas + webWorker渲染
html
<template lang="pug">
canvas(ref="gkDvsWaterfallCanvasRef")
</template>
<style>
canvas {
position: absolute;
width: 100%;
height: 100%;
}
</style>
发送离屏渲染能力到后台worker
javascript
const width = gkDvsWaterfallContainerRef.value.offsetWidth
const height = gkDvsWaterfallContainerRef.value.offsetHeight
// 把canvas转成离屏绘画对象
const offscreen = gkDvsWaterfallCanvasRef.value.transferControlToOffscreen()
worker.value.postMessage(
{
type: 'config',
data: {
canvas: offscreen,
{width, height}
},
}
)
worker代码:
javascript
// self 是 Web Worker 中的全局对象,就像浏览器主线程中的 window 一样
self.onmessage = (event) => {
if (event.data.type === 'config') {
dataCanvasEl = data.canvas
dataCtx = dataCanvasEl.getContext('2d')
dataCtx.fillRect(0, 0, 100, 100); // 绘制后自动显示在页面的 <canvas> 上!
}else if ...
}
// 向主线程发送信息
// self.postMessage({ type: 'camera', data: imgData })
// Worker 线程
const ctx = offscreen.getContext('2d');
ctx.fillRect(0, 0, 100, 100); // 绘制后自动显示在页面的 <canvas> 上!
注意,不需要通讯。在worker里绘画的话,通过共享内存,前台就能同步显示。

两种离屏画布的区别
transferControlToOffscreen和new OffscreenCanvas都是离屏画布,但是本质上有很大的区别!

transferControlToOffscreen:

new OffscreenCanvas:
