项目总结

项目总结
晨梦权限
菜单权限
账号登陆
传递账号、密码、时间戳、验证码
请求图形验证码 要传递参数key 返回的数据通过new Blob解析出来
账号密码需使用AES加密
手机验证码登陆
校验规则 输入手机号使用AES加密 发送成功后 开始倒计时
微信扫码登陆
后端和微信方做sdk的交互(key,密钥,网址)按钮[微信登录] -> 弹出窗口上面是微信提供的二维码,前端拿到数据,渲染到页面上
扫码->手机端点击允许 拿到对应的code码
APP扫码登录
1、PC端请求后端生成二维码,后端创建一个全局唯一的二维码ID,并存储二维码状态(初始状态:待扫描)。
2、生成二维码,二维码绑定了二维码ID等待用户进行扫描,此时前端会不断轮询接口 根据二维码id查询二维码状态,一旦状态改变页面也会同步改变
3、手机扫码,携带App token/二维码ID请求后端扫描登录接口,后端也会校验App用户Token变更二维码状态为已使用
4、此时PC端的轮询状态变更已扫描,把界面呈现待确认,后端生成临时token和二维码关联,保证当前二维码只会被扫描一次,后端再把临时token返回给手机,手机端也是待确认的状态端,那手机端就使用临时token请求后端,后端会通过临时token拿到二维码的id,再根据二维码id变更二维码的状态为已使用,然后再执行登陆的逻辑生成pc端的登陆token,保存在redis中,删除 临时Token,防止重复使用
5、PC端轮询检测到二维码状态变更为 已使用。PC端获取登录Token,完成登录流程。
获取菜单树
使用rbac模型实现权限控制(路由,按钮级别)用户绑定角色 传统得是给每个用户单独分配权限,但是分配得时候比较费劲,有很多用户其实权限都一样,这时候给他们统一的角色分配权限就行 所以角色绑定权限 权限用于展示菜单内容
填写用户名密码验证码正确后端返回token,前端把token存储到localStorage中->获取用户个人信息、权限信息、角色信息【包含角色权限编码】(调用接口把token传递给后端,token写在请求拦截器里面,放在请求头里传递过去config.headers[‘Authorization’])拿到角色权限编码-持久化存储放在store里->获取路由(把角色权限编码传递给后端)返回菜单权限树就是左侧菜单->数据重构扁平化后路由使其能动态添加router.addRoute(‘’,{})
按钮权限
前端请求个人信息接口
后端给前端返回:当前登录的这个用户的个人信息、权限信息(其中包括判断具体的权限按钮)permissionss是个字符串组成的数组、角色信息。然后给节点添加自定义指令对应的value值,如果这个用户拥有这个权限那么按钮则存在,如果没有这个权限,那么就removeCild(删除)这个按钮,那么对应的用户也就不能点,就没有这个权限
前端做法:
1.创建directive文件夹 mian.js挂载全局
<el-button v-auths="'crm:media:channel:delete'">删除</el-button>
import { useUserStore } from '@store/useUserStore'
export const AuthDirectives = {
name: 'auths',
mounted(el, binding) {
console.log(el, binging) // el是dom binging是绑定的权限编码
let permissions = useUserStore().permissions;
if( permissions.includes("*:*:*") ) return; // 如果是最高权限直接return
if( permissions.includes(binding.value) ) {
const parent = el.parentElement;
parent && parent.removeChild( el )
}
}
}
添加用户
根据不同用户展示不同权限,那么这个用户怎么添加的
1.为用户先创建角色,这个角色其实就是具体的权限。
2.创建用户的时候,对应选好角色(那么其实就是选择好了权限)
怎么优化性能
加载性能优化(减少资源体积和请求数量)
资源压缩与合并
TML、CSS、JS 压缩(例如使用 Terser、cssnano)。
图片压缩(如使用 WebP、AVIF 替代 JPEG/PNG)。
懒加载
图片懒加载:使用 loading=”lazy” 或手动监听 IntersectionObserver。
路由懒加载:Vue Router 中使用动态 import()。
第三方库懒加载:按需引入,避免全量引入。
缓存策略
使用浏览器缓存(Cache-Control、ETag、Service Worker 等)。
CDN 加速静态资源分发。
本地持久化存储(如 localStorage / IndexedDB)缓存部分数据。
渲染性能优化
减少重排重绘
避免频繁修改 DOM(合并操作,使用 DocumentFragment)。
避免逐条设置样式,改用 class 切换。
合理使用 will-change、transform、opacity 避免触发 layout。
CSS 优化
避免使用低效选择器(如通配符 *)。
限制嵌套层级,减少复杂计算。
使用合适的 display 和 position 避免无效占位。
骨架屏与占位图: 页面加载慢时使用骨架屏或 loading 动画增强用户感知。
运行时性能优化
事件节流与防抖: 滚动、输入、resize 等高频事件使用 throttle / debounce 限流
虚拟滚动: 对大列表使用虚拟滚动技术(如 VueVirtualScroller、element-plus 的 virtual-scroll)减少 DOM 数量。
避免内存泄漏: 正确解绑事件监听、定时器、WebSocket。路由切换时销毁组件中未清除的引用
异步处理: 使用 requestAnimationFrame 优化动画。使用 web worker 处理复杂计算,避免阻塞主线程。
框架层优化
组件优化
使用 v-show 代替频繁切换的 v-if。
大型组件懒加载 + 异步组件。
合理使用 keep-alive 缓存组件状态。
响应式性能优化: computed 替代不必要的 watch
避免不必要的响应式绑定: 尽量使用普通对象存储静态数据,不放入 reactive 中
构建工具层优化
利用 SplitChunks 拆分第三方库
开启生产模式(mode: production),自动开启压缩
怎么封装公共组件
1.确认动机
例如:
页面太复杂了,想分一块出去,减少当前页面的复杂度
很多的组件都要使用同一个功能,需要把多有的功能提出去,所以这个组件要有一些通用性的特征
2.分析边界
越通用边界越窄越灵活便利性越低,不要过度封装,避免限制其适用性。
3.设计接口 通用性的组件写文档
属性 插槽 事件
4.代码实现
5.功能测试-单元测试/集成测试
6.后续维护
优化、功能更新
话术:比如我封装过分页器就是想提高复用性,所以需要支持当前页,每页条数,总数,需要 双向绑定,让父组件能够控制分页器的状态,需要提供 回调事件,让外部组件监听分页变更
<template>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[2, 5, 10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'Pagination', //组件名称
})
</script>
<script lang="ts" setup>
import { ref } from 'vue'
const props = defineProps({
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 10,
},
total: {
type: Number,
default: 10,
},
})
// 分页
const currentPage = ref(props.currentPage) // 当前页
const pageSize = ref(props.pageSize) // 每页多少条
// 用于双向绑定 currentPage,当用户切换分页时,通知父组件更新数据。当用户调整每页显示条数时,通知父组件
const emits = defineEmits(['update:currentPage', 'update:pageSize'])
//分页-一页显示多少条
const handleSizeChange = (page: number) => {
pageSize.value = page
emits('update:pageSize', page)
}
//分页-页码
const handleCurrentChange = (page: number) => {
currentPage.value = page
emits('update:currentPage', page)
}
</script>
<style lang="scss" scoped>
.pagination {
display: flex;
justify-content: flex-end;
padding: 15px 0;
}
</style>
话术:比如我封装过树状结构,递归渲染树形结构,支持节点展开/收起功能,允许指定选中某个节点,允许监听节点点击事件。可自定义图标大小、节点间距等样式。
data:树形结构的数据(包含 children 递归渲染)。
iconWidth / iconHeight:用于控制图标大小。
appointKey:指定唯一标识字段,默认是 id,用于选中节点的判断。
node-click:当用户点击某个节点时,触发该事件,将选中的节点对象传递给父组件。
采用 递归组件 方式渲染树状结构,支持 展开/收起 交互。
如何将echarts大屏导出为图片
方法 1:使用 ECharts 的 getDataURL 方法
ECharts 提供了 getDataURL() 方法,可将图表转换为 Base64 格式的图片,然后可以:
直接 显示 在页面上
提供 下载 按钮,让用户保存
方法 2:整个大屏截图导出
导出整个 ECharts 大屏(不仅是一个图表),可以使用 html2canvas 进行整个页面截图。安装 html2canvas npm install html2canvas
<template>
<div ref="screenRef">
<div ref="chartRef" style="width: 800px; height: 400px;"></div>
<button @click="exportScreen">导出大屏</button>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import html2canvas from 'html2canvas'
const chartRef = ref(null)
const screenRef = ref(null)
let chartInstance = null
onMounted(() => {
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption({
title: { text: 'ECharts 大屏示例' },
tooltip: {},
xAxis: { data: ['A', 'B', 'C', 'D', 'E'] },
yAxis: {},
series: [{ type: 'bar', data: [5, 20, 36, 10, 10] }]
})
})
// 导出整个大屏
const exportScreen = async () => {
html2canvas(screenRef.value, {
useCORS: true, // 允许跨域图片
scale: 2 // 提高清晰度
}).then(canvas => {
const image = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.href = image
link.download = 'big-screen.png'
link.click()
})
}
</script>
异步下载任务
1.点击导出试卷的时候 请求接口,传递两个参数,一个是试卷名称一个是试卷状态 导出成功和失败都给提示
2.这里不是直接的导出(下载),是要通过异步的方式来下载
3.导出数据需要在系统中,点击导出下载进入任务列表页
4.在任务列表页,展示出对应的导出数据,并且点击某一项的下载,进行下载操作
5.通过node.js的https模块的get去发送请求,通过fs管道来进行保存文件
使用到了node.js中的https模块的get方法来进行下载文件的url请求,并且使用node.js的fs模块加入管道来保存文件
前端
│ 点击导出 → POST /api/export
↓
后端API
│ 创建任务记录 → 数据库
↓
任务队列(Bull)
│ Worker进程处理
↓
HTTPS请求外部资源 → 流式保存到磁盘
│ 成功 → 更新任务状态
↓
任务列表页 ← GET /api/tasks
│ 点击下载 → GET /api/download/:id
↓
文件流返回 → 浏览器下载
// Worker处理示例
const https = require('https');
const fs = require('fs');
exportQueue.process(async (job) => {
const { taskId, name, status } = job.data;
const filePath = `./exports/${taskId}.pdf`;
try {
// 假设根据参数生成下载URL
const url = buildExportURL(name, status);
await downloadFile(url, filePath);
await db.collection('tasks').updateOne(
{ _id: taskId },
{ $set: { status: 'completed', filePath } }
);
} catch (error) {
await db.collection('tasks').updateOne(
{ _id: taskId },
{ $set: { status: 'failed', error: error.message } }
);
}
});
function downloadFile(url, filePath) {
return new Promise((resolve, reject) => {
https.get(url, (response) => {
const fileStream = fs.createWriteStream(filePath);
response.pipe(fileStream);
fileStream.on('finish', () => resolve());
fileStream.on('error', reject);
}).on('error', reject);
});
}
大文件上传
基于 Element-Plus Upload 组件封装大文件上传功能
思路
前端
1.得到文件,通过el-upload组件得到上传文件;
2.文件切片,将上传文件按照预先定义好的单个切片大小,将文件切分成一个个的切片; (得到的上传文件是Blob文件, 可以通过slice方法对文件进行截取,从而按指定大小进行切片)
3.切片编号,由于是并发,传输到服务器的顺序可能会发生变化, 因此还需要给每个切片记录顺序
4.分片上传,然后借助http的可并发性,同时上传多个切片。这样从原本的一个大文件,变成了并发传多个小的文件切片,可大大减少上传时间
5.合并切片,上传完毕通知服务器切片上传完了,让其合并切片,完成上传
代码拆分
自定义 el-upload 组件的上传方式(得到文件)
<!-- http-request 覆盖默认的 Xhr 行为,允许自行实现上传文件的请求 --> <el-upload v-model:file-list="fileList" :limit="FILE_MAX_LENGTH" :on-exceed="handleExceed" :before-upload="beforeUpload" :http-request="handleUpload" > <el-button type="primary">点击上传</el-button> <template #tip> <div class="el-upload__tip">上传文件大小不能超过1GB</div> </template> </el-upload>handleUpload 回调中获取上传的文件,对其进行切片(文件切片)
// size就是以多大为标准切 const createChunkFileList = (file: File, size = CHUNK_SIZE) => { // 最后数组里放的就是一个个的切片 const chunkFileList = []; let currentSize = 0; while (currentSize < file.size) { // 第一次 0 - 10mb // 第二次 10mb - 20mb // ... chunkFileList.push(file.slice(currentSize, currentSize + size)); currentSize += size; } return chunkFileList; };将切片的数据进行维护成一个包括该切片文件,切片名的对象(切片编号)
// 一个个切片组成的数组和文件名称 const transformChunkFileList = (chunkFileList: Blob[], name: string) => { return chunkFileList.map((chunkFile, index) => { return { chunk: chunkFile, // 切片文件 hash: `${name}-${index}`, // 切片名 }; }); };上传切片(切片上传)
const uploadChunks = async (fileList: ChunkFileList, name: string) => { const requestList = fileList .map(({ chunk, hash }) => { // FormData 是浏览器提供的一个构造器 “用于构造表单提交数据的对象”,可以通过 .append() 方法向其中添加字段或文件。 const formData = new FormData(); formData.append("chunk", chunk, hash); formData.append("filename", name); return formData; }) .map((formData) => { return request({ url: "http://localhost:3000/upload/file", data: formData, onProgress: handleProgress(name), }); }); // 并发请求 await Promise.all(requestList); };上传完所有切片,通知服务器合并上传切片成一个文件(合并切片)
const mergeRequest = (name: string) => { return request({ url: "http://localhost:3000/merge/file", headers: { "content-type": "application/json" }, data: JSON.stringify({ filename: name, }), }); };
显示上传进度
上传之前,初始化上传进度
// 代表已经上传的文件大小 file.loaded = 0; // 让进度条开始显示 file.status = "uploading";上传切片的每个请求添加 onprogress
// 当这个请求上传中会触发 onprogress xhr.upload.onprogress = onProgress;onprogress 中累加已经上传的文件大小,并计算上传总进度
if (e.loaded === e.total) { // 已经加载的大小 file.loaded += e.loaded; // 计算百分比: el-upload组件会根据percentage的值来改变进度条的进度 file.percentage = Number( ((file.loaded / (file.size as number)) * 100).toFixed(2) ); }当上传总进度达到 100 时,需要结束进度条
if (file.percentage === 100) { // 进度条结束,显示成功的图标 file.status = "success"; }
断点续传
断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能
- 前端方案: 前端使用 localStorage 记录已上传的切片 hash
- 服务端方案: 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片
前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以我们选服务端方案
步骤:
1. 使用 web-worker 根据文件内容生成 hash。
之前我们使用文件名 + 切片下标作为切片 hash,这样做文件名一旦修改就失去了效果。而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash。
可以使用库 spark-md5,它可以根据文件内容计算出文件的 hash 值。
由于文件内容比较大,计算 hash 比较耗时,会引起 UI 的阻塞,导致页面假死状态。所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互
2.计算的 hash 发送给服务器,用来作为文件的名称
3.服务器将文件以 hash 的名称保存起来
恢复上传
- 携带 hash 参数发送请求给服务器,获取该文件是否上传过;
- 如果上传过,无需上传(实现秒传);
- 如果只上传了一部分,获取上传的一部分切片名称,前端上传时跳过这些已经上传切片,这样就实现了“续传”的效果;(过滤)
- 如果没有上传过就直接全部上传即可。
后端两个地方 一个是专门用来传递的 一个是存起来的切片文件夹
完整代码
<template>
<el-upload
v-model:file-list="fileList"
:limit="FILE_MAX_LENGTH"
:on-exceed="handleExceed"
:before-upload="beforeUpload"
:http-request="handleUpload"
>
<el-button type="primary">点击上传</el-button>
<template #tip>
<div class="el-upload__tip">上传文件大小不能超过1GB</div>
</template>
</el-upload>
</template>
<script lang="ts">
export default {
name: "Upload",
};
</script>
<script lang="ts" setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";
import type {
UploadProps,
// UploadUserFile,
UploadRequestOptions,
} from "element-plus";
import type {
ChunkFileList,
UploadCustomFile,
OnProgress,
RequestHandler,
} from "./type";
// 上传的文件列表
const fileList = ref<UploadCustomFile[]>([]);
// 允许上传的文件最大数量
const FILE_MAX_LENGTH = 5;
// 上传超出限制
const handleExceed: UploadProps["onExceed"] = () => {
ElMessage.warning(`上传图片数量不能超过${FILE_MAX_LENGTH}个!`);
};
// 上传之前
const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => {
if (rawFile.size / 1024 / 1024 / 1024 > 1) {
ElMessage.error("上传大小不能超过1G!");
return false;
}
return true;
};
// 使用 web-worker 创建 hash
const createHash = (chunkFileList: Blob[]): Promise<string> => {
return new Promise((resolve) => {
// 创建worker
const worker = new Worker(
new URL("./createHashWorker.js", import.meta.url),
{
type: "module",
}
);
// 给worker发送消息
worker.postMessage(chunkFileList);
// 接受worker的消息
worker.onmessage = (e) => {
const { hash } = e.data;
if (hash) {
resolve(hash);
}
};
});
};
// 切片上传
const handleUpload = async (options: UploadRequestOptions) => {
// 1. 生成文件切片
const chunkFileList = createChunkFileList(options.file);
const name = options.file.name;
// 生成文件的hash
const hash = await createHash(chunkFileList);
// 2. 将切片的数据进行维护成一个包括该切片文件,切片名的对象
const transformFileList = transformChunkFileList(chunkFileList, name, hash);
// 上传之前,初始化上传进度
fileList.value.forEach((file) => {
if (file.name === name) {
file.loaded = 0;
file.status = "uploading";
}
});
// 确认上传切片情况
const { data } = await verifyRequest(name, hash);
if (!data.needUpload) {
console.log(data.url);
return;
}
// 3. 上传切片
await uploadChunks(transformFileList, name, data.chunkFileList);
// 4. 合并
const res = await mergeRequest(name, hash);
console.log(res);
};
// 自定义切片大小 10mb
const CHUNK_SIZE = 10 * 1024 * 1024;
// 1. 生成文件切片
const createChunkFileList = (file: File, size = CHUNK_SIZE) => {
const chunkFileList = [];
let currentSize = 0;
while (currentSize < file.size) {
// 第一次 0 - 10mb
// 第二次 10mb - 20mb
// ...
chunkFileList.push(file.slice(currentSize, currentSize + size));
currentSize += size;
}
return chunkFileList;
};
// 2. 将切片的数据进行维护成一个包括该切片文件,切片名的对象
const transformChunkFileList = (
chunkFileList: Blob[],
name: string,
hash: string
) => {
return chunkFileList.map((chunkFile, index) => {
return {
chunk: chunkFile, // 切片文件
hash: `${name}-${hash}-${index}`, // 切片名
};
});
};
// 3. 自定义上传切片的方法
const request: RequestHandler = ({
url,
data,
method = "post",
headers = {},
onProgress,
}) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
if (onProgress) {
xhr.upload.onprogress = onProgress;
}
Object.keys(headers).forEach((key) =>
xhr.setRequestHeader(key, headers[key])
);
xhr.addEventListener("load", (e) => {
if (xhr.status < 200 || xhr.status >= 300) {
reject("失败");
}
resolve(JSON.parse((e.target as XMLHttpRequest).responseText));
});
xhr.send(data);
});
};
const handleProgress = (name: string): OnProgress => {
return (e) => {
fileList.value.forEach((file) => {
if (file.name === name) {
if (e.loaded === e.total) {
// 已经加载的大小
file.loaded += e.loaded;
// 计算百分比
file.percentage = Number(
((file.loaded / (file.size as number)) * 100).toFixed(2)
);
if (file.percentage === 100) {
file.status = "success";
}
}
}
});
};
};
// 确认上传切片情况
const verifyRequest = (filename: string, hash: string) => {
return request({
url: "http://localhost:3000/verify/file",
headers: { "content-type": "application/json" },
data: JSON.stringify({
filename,
fileHash: hash,
}),
});
};
// 4. 上传切片
const uploadChunks = async (
fileList: ChunkFileList,
name: string,
chunkFileList: string[] = [] // 已经上传好的切片
) => {
const requestList = fileList
.filter((file) => {
return !chunkFileList.some((chunkFile) => file.hash.includes(chunkFile));
})
.map(({ chunk, hash }) => {
const formData = new FormData();
formData.append("chunk", chunk, hash);
formData.append("filename", name);
return formData;
})
.map((formData) => {
return request({
url: "http://localhost:3000/upload/file",
data: formData,
onProgress: handleProgress(name),
});
});
// 并发请求
await Promise.all(requestList);
};
// 5. 通知服务器合并上传切片成一个文件
const mergeRequest = (name: string, hash: string) => {
return request({
url: "http://localhost:3000/merge/file",
headers: { "content-type": "application/json" },
data: JSON.stringify({
filename: name,
fileHash: hash,
}),
});
};
</script>
<style scoped></style>
// 导入脚本
import SparkMD5 from "spark-md5";
// 生成文件 hash
self.onmessage = (e) => {
const chunkFileList = e.data;
const spark = new SparkMD5.ArrayBuffer();
let currentChunk = 0;
const loadNext = (index) => {
const reader = new FileReader();
reader.readAsArrayBuffer(chunkFileList[index]);
reader.onload = (e) => {
currentChunk++;
spark.append(e.target.result);
if (currentChunk === chunkFileList.length) {
self.postMessage({
hash: spark.end(),
});
self.close();
} else {
loadNext(currentChunk);
}
};
};
loadNext(0);
};
// type.ts
export interface ChunkFile {
chunk: Blob;
hash: string;
}
export type ChunkFileList = ChunkFile[];
interface XMLHttpRequest {
loaded: number;
total: number;
}
export interface RequestType {
url: string;
data: any;
headers?: {
[key: string]: any;
};
method?: "post";
}
export interface ResponseType<T = any> {
code: number;
message: string;
data: T;
success: boolean;
}
export interface RequestHandler<T = any> {
(options: RequestType): Promise<ResponseType<T>>;
}
文件上传时axios配置区别
使用 FormData 上传:FormData 是浏览器提供的一个构造器 “用于构造表单提交数据的对象”,可以通过 .append() 方法向其中添加字段或文件。
formData.append(“chunk”, chunk, hash);
formData.append(“filename”, name);
添加每一个切片文件和对应的hash以及文件名称
视频模块优化
首页视频列表太多会影响首屏加载速度,因此我们使用了 IntersectionObserver 监听视频卡片是否进入视口。进入视口后再加载视频封面和 metadata元数据。同时利用 requestIdleCallback 空闲时间预加载热门视频的第一段分片,让用户点击后几乎秒开。首屏加载时间下降了约 40%。
懒加载与预加载结合:
视频卡片滚动进入视口时再加载视频(懒加载)
在浏览器空闲时预加载视频的第一段分片(预加载)
思路:
- 先封装一个视频卡片组件。你在一个视频列表页面,会渲染很多这样的卡片。ref=”cardRef”给整个卡片绑定一个引用,后面用来监听是否出现在屏幕上,v-if=”!visible”:如果还没进入屏幕,显示一张图片;v-else:如果已经进入视口了,才显示
<video>视频标签, preload=”metadata”:只加载元数据(而不是整个视频),节省资源 - 懒加载:监听是否进入视口,使用 IntersectionObserver 监听这个卡片元素是否“出现在可视区域”,一旦进入视口,设置 visible.value = true,显示视频,停止监听(disconnect())
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
visible.value = true
observer.disconnect()
}
})
- 预加载首个分片:
这部分在浏览器“空闲时”做预加载:通过 requestIdleCallback,避免阻塞主线程,用 Range 请求视频文件的前 100KB(可包含 .ts 分片或 HLS index),达到“边下边播”优化
requestIdleCallback(() => {
fetch(props.src, {method: 'GET', headers: {Range: 'bytes=0-100000'}})
})
长期运行的3D场景内存管理
在实际项目中,Three.js 并不会自动释放 GPU 和 JS 内存资源,开发者必须主动调用 .dispose() 方法释放资源,否则会导致内存持续增长、性能下降甚至崩溃。特别是对于 >8 小时的持续运行场景,必须构建资源回收机制、定期清理无用对象、控制动画循环以及事件监听器的使用。
背景:Three.js 在长时间运行的 3D 场景中,若未妥善管理资源(如纹理、几何体、材质、监听器等),极易造成内存泄漏,进而引发性能下降甚至崩溃,特别是在设备性能受限的浏览器环境下
工具辅助分析:
使用浏览器开发者工具(Chrome DevTools)中的 Memory > Heap Snapshot 检查未释放对象
处理方式:
正确释放资源:在不使用几何体、材质、纹理等资源时,要及时释放它们
移除不再使用的对象: 当场景中移除它们
// 释放几何体和材质 mesh.geometry.dispose(); mesh.material.dispose(); // 释放纹理 texture.dispose(); // 从场景中移除对象 scene.remove(mesh);控制器、渲染器资源释放
controls.dispose(); // 如 OrbitControls renderer.dispose(); // 销毁 WebGL 上下文资源清理事件监听器: 如果为对象添加了事件监听器,在对象不再使用时,要及时移除这些监听器, 避免内存泄漏
cancelAnimationFrame(animationId); window.removeEventListener('resize', onResize);防止重复创建资源(缓存复用)
使用资源管理器加载复用模型、纹理等
避免每次切换都新建材质/几何体使用 WebGL 渲染器的清理函数
renderer.forceContextLoss(); // 手动释放 GPU 资源
renderer.context = null;
renderer.domElement = null;
如果不及时释放不再使用的资源,内存消耗会逐渐增大,最终导致性能下降,甚至浏览器崩溃。
1)确保及时销毁不再使用的资源
Three.js 中的许多对象(如 Geometry, Material, Texture, Mesh, Scene 等)都占用 WebGL 的资源。当这些对象不再使用时,必须手动释放它们。dispose()
2)定期清理和重置场景
长时间运行时,定期清理不再使用的对象是防止内存泄漏的重要措施。每隔一段时间,可以使用如下方法检查并移除不再需要的物体,或者将不再可见的物体从场景中移除。自定义判断物体是否可见
3)清理事件监听器
Three.js 中的对象可能会有一些事件监听器,如果不清理它们,也可能导致内存泄漏。例如,场景中的对象可能会注册鼠标事件、窗口大小调整事件等。如果这些监听器不再需要,但没有移除,也会导致内存泄漏。window.removeEventListener
4)减少动画渲染过度
长时间运行时,动画和帧渲染的频率可能导致大量的资源被分配到 GPU 和内存中。如果不再需要动画,或者某些动画可以暂停,可以减少渲染的次数。
5)释放和清理纹理和缓存
使用 THREE.Texture 对象时,尤其是动态加载纹理时,确保及时清理不再使用的纹理资源。如果纹理缓存未及时清除,会导致显存泄漏。
6)使用对象池 来复用资源,减少不必要的内存分配 对象池通过预先创建一定数量的对象,当需要一个新对象时,池中有可用的对象时直接返回,而不是重新创建一个新的对象。当对象不再使用时,应该将它归还到对象池中,而不是销毁它。这样就避免了频繁的对象创建和销毁,提高了性能。
echarts大屏适配
不管用户用多大分辨率的屏幕,我们的图表都能按比例缩放,完整显示在屏幕中间,并且不被裁剪。
我们会根据浏览器窗口的宽高,去和设计稿的宽高做一个比例比较,然后按照这个比例整体缩放页面内容。这样图表就不会变形,也不会溢出。
- 大屏适配的核心是通过 父盒子相对定位 和 子组件绝对定位 的方式,将内容居中展示,并通过
scale动态调整大小,实现适配不同分辨率的屏幕
- 父盒子设置
position: relative;,为子组件提供定位上下文。 - 子组件设置
position: absolute;,通过transform实现居中显示,同时利用scale按比例缩放内容。
- 封装适配组件
- 将适配逻辑封装成 Vue 组件,支持动态宽高设置。
- 通过
scale属性动态调整子组件的缩放比例,确保宽高比一致。
总结
父盒子相对定位,子组件绝对定位,并通过transform: translate(-50%, -50%)将子组件居中。
动态计算屏幕与设计稿的宽高比例,取最小值作为缩放比例,并使用scale动态缩放子组件。.container { width: 100%; height: 100%; background-image: url(./images/bg.png); background-repeat: no-repeat; background-size: cover; position: relative; .content { overflow: hidden; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); // 居中 transform-origin: 0 0; width: 2380px; height: 1300px; padding: 0 10px; } }
<template>
<div
class="ScreenAdapter"
:style="style"
>
// 插槽
<slot />
</div>
</template>
<script>
export default {
name: 'ScreenAdapter',
props: {
width: {
type: String,
default: '2775'
},
height: {
type: String,
default: '1730'
}
},
data() {
return {
style: {
width: this.width + 'px',
height: this.height + 'px',
// scale(1):初始缩放比例为 1。
// translate(-50%, -50%):用于将容器中心点定位到屏幕的正中心。
transform: 'scale(1) translate(-50%, -50%)'
}
}
},
// 在组件挂载时,调用 setScale() 初始化缩放比例。添加 window.onresize 事件监听器,当窗口大小变化时调用 setScale,实时调整适配比例,使用 debounce 对 setScale 进行防抖处理,避免频繁执行
mounted() {
this.setScale();
window.onresize = this.debounce(this.setScale, 1000);
},
methods: {
//防抖函数 最后一次触发
debounce(fn, delay) {
let timer;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, delay);
};
},
// 计算当前屏幕的宽度和高度相对于设计稿宽高的缩放比例 取宽高比例的最小值,确保内容完全适配屏幕
getScale() {
const w = window.innerWidth / this.width;
const h = window.innerHeight / this.height;
return Math.min(w, h);
},
setScale() {
this.style.transform = `scale(${this.getScale()}) translate(-50%, -50%)`;
}
}
}
</script>
<style lang="scss" scoped>
.ScreenAdapter {
transform-origin: 0 0;
position: absolute;
left: 50%;
top: 50%;
transition: 1s;
}
</style>
echarts封装
通过 props 接收外部配置 增加支持多种图表类型的逻辑(如通过 type 动态渲染折线图、柱状图等)。 使用 slots 支持图表标题、图例等的定制化内容。通过 options 接收父组件传递的 ECharts 配置项 数据 使用 Vue 的 watch 监听 props.options 的变化,并更新图表。
getOption 函数返回 ECharts 图表配置对象,通过 props 和动态数据驱动配置。
将 legend(配置了 textStyle(颜色、字体大小)、itemWidth 和 itemHeight(图例宽高)、itemGap(图例间距)。)、tooltip(提示框trigger: 'axis',提示框在鼠标悬浮到轴上时显示)、grid(设置 top、left、right 和 bottom,保证图表在容器中合理分布。
自动布局: 开启 containLabel,避免图表内容与边界重叠 )、xAxis、yAxis、series(数据系列 柱子宽度,颜色,label柱子上面的数字显示) 等部分按照 ECharts 的标准配置进行模块化拆分,方便阅读和修改。
提供高度自定义的样式设置(如颜色、字体、间距等),满足不同业务需求。
二次封装axios
- 携带统一的请求前缀 + 超时时间
- 进度条的添加(也可以在路由守卫去做)
- 返回的响应不再是响应,而是响应里面的数据data
- 统一处理请求错误,也可以选择后续继续处理或者不处理
发送请求,携带公共请求参数:token
返回响应,根据 code / status 判断请求成功还是失败
- 成功返回成功的数据
- 失败提示失败的准确原因
取消重复请求
- 每个请求根据请求的配置项生成一个相应 key(相同的请求生成的 key 相同,不同的请求生成的 key 不同)
- 发送请求的请求拦截器中,判断当前请求是否在请求列表容器中存在,存在即取消上一个请求,axios取消请求的cancelToken使用
- 如果当前请求在请求列表容器中不存在,需要将其存储起来
- 请求完成,在响应拦截器中需要将当前请求给删除
取消上一个页面的请求
- 存储请求时,需要将取消请求的方法和当前路由路径一起储存
- 当路由跳转时,判断请求列表中的请求地址和要去的路由地址是否时同一个,如果不是,就要取消
3D可视化的高拟真度
模型加载与优化
我们使用了 GLTF 格式加载设备模型,体积小,加载快,支持 PBR 材质效果;
对模型做了面数优化、材质合并、贴图压缩,确保复杂场景下资源占用可控;
使用 DRACOLoader 实现压缩传输,显著减少加载时间。
性能优化策略
启用了 requestAnimationFrame 控制动画节奏,避免不必要的重绘;
使用 frustum culling(视锥体裁剪) 和 场景分层渲染,只渲染视野内设备;
合理管理 lights 和 shadows,避免全局光源开销过大;
将动画状态控制与渲染分离,提升并发渲染效率。
生产工艺动画的实现
利用 Three.js 中的 AnimationMixer 控制动画播放、暂停与过渡;
动画节奏模拟真实生产节拍,路径运动使用了 CatmullRomCurve3 曲线路径;
每台设备有独立的状态管理,结合实际流程分步推进动画,提升拟真度;
动画中还集成了文字标注、粒子效果等增强信息维度。
并发冲突控制
你是如何实现虚拟化生产流程的同步机制的?尤其在多个学生端同时操作同一个设备的开关时,如何处理并发冲突并保持 3D 场景与后台数据的秒级同步?
回答
在我们负责的虚拟工厂模拟系统中,为了让多个学生同时参与生产流程控制,我们设计了一个前端与后台秒级同步机制 + 并发冲突处理策略,重点解决两个核心问题:
🎯 1. 场景状态同步:前端与后端的实时联动
每个设备的开关状态、运行状态都由后端统一维护设备状态中心;
前端定时(每秒或定间隔)通过接口轮询或使用 WebSocket 获取设备最新状态;
前端根据状态实时更新 Three.js 场景中的动画、颜色或提示信息,实现秒级同步体验;
若设备状态发生变化(如从“关闭”变为“开启”),立即切换对应的模型动作、贴图材质或播放动画。
⚙️ 2. 并发冲突处理:多个学生同时操作同一设备
每个学生端操作(如点击“开启设备”按钮)会向后台发起接口请求;
后台设置了 设备状态锁机制,即当某设备处于“执行中”状态时,其他请求将被排队或返回“设备忙”提示;
请求中会携带操作时间戳或用户 ID,用于后台判断哪个请求优先;
后台只允许第一个有效请求修改设备状态,后续请求返回当前设备最新状态,前端收到后更新 UI;
所有设备状态的变化会通过广播或定时轮询同步给所有学生端,保持一致性。
💡 示例场景:
学生 A 和学生 B 同时点击“开启设备”;
后台接收到 A 的请求,锁定设备状态为“开启中”,成功修改;
B 的请求进入时设备状态已变,不再执行修改操作,返回“设备已开启”;
前端 UI 自动刷新状态,展示一致。
websocket
背景与需求:
在一个复杂的前端项目中,需要实现自动版本检测和页面自动刷新功能,确保用户始终访问到最新的线上版本。同时,项目还集成了 WebSocket 实时通信,要求断线时能智能重连并在重连失败时自动刷新页面,保证系统稳定性和用户体验。
遇到的问题:
如何准确检测线上版本变化,避免缓存导致的版本检测失效?
如何设计高效的自动刷新机制,防止频繁刷新影响用户操作?
WebSocket连接不稳定,断线后如何控制重连次数及失败后的自动刷新?
在无数据返回或长时间无响应时,如何避免页面卡死,提升用户体验?
业界解决方案分析:
版本检测通常通过构建时生成的版本文件(如version.json)配合前端定时轮询实现。
自动刷新常用定时器加时间戳防缓存,防止页面缓存问题。
WebSocket重连机制需限制最大重连次数,防止无限重连导致资源浪费。
长时间无数据时,采用定时刷新或断线重连提示,确保用户界面活跃且不会卡死。
我的具体解决方案:
在项目构建流程中集成Vite插件,自动生成带时间戳的version.json版本文件,前端通过带时间戳的fetch请求轮询版本,保证无缓存。
设计自动刷新机制:首次检测到版本变化自动刷新页面,且避免频繁刷新,添加时间间隔控制。
WebSocket管理中,增加最大重连次数限制,超过次数后不再重连,改为设置1小时后自动刷新页面,提升断线容错能力。
针对无数据或长时间无响应情况,增加定时刷新策略,每隔一分钟刷新一次,避免页面卡死,同时保证数据及时更新。
落地效果与价值:
项目版本管理和更新推送机制更加稳健,用户体验大幅提升,避免了版本错乱和缓存带来的问题。
WebSocket连接稳定性显著提高,断线重连逻辑合理,降低服务器压力,减少资源浪费。
自动刷新策略避免了页面卡死现象,保障系统可用性与用户操作流畅性。
该解决方案经过多次迭代验证,应用于多个项目,提升团队对版本管理和实时通信的掌控能力。
import eventBus from './eventBus'
// 定义 WebSocket 消息类型
enum ModeCodeEnum {
MSG = 'message', // 普通消息
}
class MyWebSocket {
private websocket: WebSocket | null = null
private reconnectTimer: any = null // 断线重连定时器
private autoReloadTimer: any = null // 自动刷新定时器
private isReconnect = false // 记录是否断线重连
private webSocketState = false // 记录 WebSocket 连接状态
private reconnectAttempts = 0 // 当前重连次数
private maxReconnectAttempts = 3 // 最大重连次数
constructor(private url: string) {}
/**
* 初始化 WebSocket 连接
* @param isReconnect 是否断线重连
*/
init(isReconnect = false) {
this.isReconnect = isReconnect
this.websocket = new WebSocket(this.url)
// 绑定 WebSocket 事件
this.websocket.onopen = this.openHandler.bind(this)
this.websocket.onclose = this.closeHandler.bind(this)
this.websocket.onmessage = this.messageHandler.bind(this)
this.websocket.onerror = this.errorHandler.bind(this)
}
// 解析接收到的消息
private getMessage(event: MessageEvent): any {
try {
return JSON.parse(event.data); // 直接返回解析后的数据
} catch (error) {
console.log('收到非JSON消息:', event.data);
return event.data; // 如果解析失败,返回原始数据
}
}
// 发送消息
sendMessage(data: object) {
try {
this.websocket?.send(JSON.stringify(data))
} catch (error) {
console.log('发送消息失败:', error)
}
}
// 连接成功后的回调函数
private openHandler() {
console.log('====onopen 连接成功====')
eventBus.emit('changeBtnState', 'open')
this.webSocketState = true
this.reconnectAttempts = 0 // 重置重连次数
}
// 收到服务器数据后的回调函数
// 收到服务器数据后的回调函数
private messageHandler(event: MessageEvent) {
const { data } = this.getMessage(event); // 解析消息
// console.log('收到的消息:', data); // 打印收到的消息
// 直接通过 EventBus 传递数据给 Vue 组件
eventBus.emit('newMessage', data);
}
// 连接关闭后的回调函数
private closeHandler() {
console.log('====onclose websocket关闭连接====')
eventBus.emit('changeBtnState', 'close')
this.webSocketState = false
this.reconnectWebSocket()
this.startAutoReload()
}
// 连接出错的回调函数
private errorHandler() {
console.log('====onerror websocket连接出错====')
eventBus.emit('changeBtnState', 'close')
this.webSocketState = false
this.reconnectWebSocket()
this.startAutoReload()
}
// 重新连接
private reconnectWebSocket() {
// if (!this.isReconnect) return
if (!this.isReconnect || !this.websocket) return;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('重连次数已达上限,停止重连')
this.close()
return
}
console.log(`尝试重新连接 WebSocket... (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`)
this.reconnectTimer = setTimeout(() => {
eventBus.emit('reconnect')
this.reconnectAttempts++
this.init(true) // 重新初始化 WebSocket
}, 5000) // 重连间隔
}
// 开始1小时后的自动刷新
private startAutoReload() {
if (this.autoReloadTimer) return // 已存在定时器则不重复设定
this.autoReloadTimer = setTimeout(() => {
console.warn('WebSocket连接失败超过1小时,自动刷新页面以尝试恢复')
location.reload()
}, 60 * 60 * 1000) // 1小时
}
// 关闭 WebSocket 连接
// close() {
// this.websocket?.close()
// this.reconnectTimer && clearTimeout(this.reconnectTimer)
// this.websocket = null
// }
close() {
if (this.websocket) {
this.websocket.close();
this.websocket = null;
console.log('WebSocket 连接已关闭');
// 在 WebSocket 关闭后,执行其他清理操作
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null
}
if (this.autoReloadTimer) {
clearTimeout(this.autoReloadTimer)
this.autoReloadTimer = null
}
}
}
export default MyWebSocket
移动端适配方案
- rem 适配方案:使用 rem 单位代替 px,通过动态设置根元素 的 font-size 来实现不同屏幕下的等比例缩放。可搭配 postcss-pxtorem 插件自动把 px 转 rem。
缺点:对设计稿要求统一(比如统一使用 375px 或 750px);// 基于设计稿 375px 的宽度 (function () { //375px 屏幕时,html font-size = 37.5px 设置 1rem = 37.5px const baseSize = 37.5; // 设计稿是 375px 宽 => 1rem = 10px function setRem() { const scale = document.documentElement.clientWidth / 375; //屏幕相对于设计稿的缩放比例: document.documentElement.style.fontSize = baseSize * scale + 'px'; } setRem(); window.addEventListener('resize', setRem); })(); - viewport 适配方案(百分比布局 + meta viewport)
通过设置 viewport 的缩放比例 + 使用百分比、vw/vh 单位进行布局,使页面在不同设备下显示一致。
使用 vw / vh 单位开发页面:<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
或者使用百分比配合媒体查询、flex 等布局方式。.container { width: 100vw; /* 屏幕宽度 */ height: 100vh; /* 屏幕高度 */ }
总结:移动端适配主要有 rem 和 viewport 两种方案。
rem 适配通过动态设置 html 的 font-size + 使用 rem 单位,能做到设计稿等比例缩放,适合大多数项目开发。
viewport 适配则更轻量,基于 vw/vh 单位,但在一些机型上兼容性稍差。
实际开发中我常使用 rem + postcss-pxtorem 插件配合,兼顾适配效果和开发效率。
虚拟滚动
只渲染视口(可见区域)中的 DOM 元素,避免一次性渲染所有数据,提升性能。
设定参数: 显示多少行数据; 行高; 截取起始下标; 截取末尾下标;总数据列表的数组
挂载后拿到 :
- 滚动列表高度 = 行高 * 显示多少行数据
- 滚动条高度(所有数据的高度) = 行高 * 整个数据组成的数组.length
绑定滚动事件:做个节流
- 拿到卷进去高度 scrollTop
- 卷进去的行数 = 卷进去高度 / 行高 【四舍五入一下】
- 截取起始下标 = 卷进去的行数
- 截取末尾下标 = 截取起始下标 + 显示多少行数据
- 卷进去的高度再平移回来内容:
translateY(${offsetTop}px)
总结:
假设我有 1000 条数据,每行高度 40px,页面一屏最多显示 10 条:
容器高度:400px
滚动容器高度:1000 * 40 = 40000px
每次滚动时,我计算 scrollTop,算出当前滚到第几行,截取这一段数据进行渲染,并通过 translateY 将数据对齐到正确位置。
uniapp做小程序登陆
1.调用uni.login这个api会返回code
2.请求微信一键登录接口把code传递给后端
会发生两种情况
2-1:用户在平台登陆并注册过,会直接返回用户的内容 + token
2-2;没有注册过用户第一次进入小程序,返回openId用户唯一标识、 会话密钥sessionKey、用户在微信开放平台账号下的唯一标识UnionID 进入到注册环节,uni.getUserProfile返回iv偏移量、rawData原始数据、encryptedData加密数据、signature签名
传递的参数就有openId,sessionKey,UnionID + uni.getUserProfile返回的数据
以上注册后会返回token
小程序支付
用户点击立即下单会向后端传递数据时间戳、uuid、关于商品的、备注、地址
后端给前端返回支付参数
调用API-uni.requestPayment({})把支付参数传过去
支付成功逻辑,跳转到“支付成功页”,支付失败处理,提示“支付已取消”
小程序分享
小程序分享主要依赖于页面生命周期中的 onShareAppMessage 和 onShareTimeline 这两个钩子函数来配置分享内容,分别对应:
分享给好友(聊天窗口)
分享到朋友圈(仅支持在具有权限的小程序中)
按钮加 open-type = ‘share’
echart 上要渲染很多个点,很卡,如何解决
当你要渲染大量点(如成千上万个散点、折线图节点等)时,页面卡顿、操作不流畅很常见。
开启大数据模式: large: true 和 largeThreshold
large: true, // ✅ 启用大数据优化
largeThreshold: 2000, // ✅ 超过这个数量启用优化
配合使用:简化样式、禁用动画等
万条数据的处理(Web Worker 实现)
场景描述:报警记录大数据处理
问题: 万条数据怎么传过来浏览器不假死 流式相应 fetch + reader持续读取数据流 一边接收一边解析JSON并渲染到页面数据来源: 这些设备每天 24 小时不间断运行,每台设备每秒产生约5-8个关键数据点(如张力、速度、温湿度、产线状态等),一个中大型无纺布工厂通常有至少50台主要设备在运行, 主要设备包括:梳理机,铺网机,水刺机,卷绕机,烘干机等
生产管理人员提出:
“我想快速筛出张力异常的报警记录,按照设备类型和时间段进行过滤,并支持多选勾选后批量标记已处理或导出。”
虽然后端提供了分页接口,但客户反映说:
“每次要点击下一页去找异常记录太麻烦,有时候根本记不得在哪页,能不能一次性加载出来、我本地筛一下?”
数据规模
在我们智能工厂项目中,报警记录数量取决于设备数与报警频率,日报警达到几万甚至10万是有可能的,尤其是在多传感器监控张力、温湿度、速度等指标下
一个工厂有 50 台设备,每台设备如果每小时有 20 条报警,如果报警规则非常敏感(比如张力波动 ±0.1 都算报警)
每台设备每天报警:20 × 24 = 480 条
50 台设备总报警:480 × 50 = 24,000 条面临的问题
后端分页返回数据,无法支持“全局模糊搜索”“跨页筛选”
客户强调“需要一次性加载+快速筛选+批量操作”
10 万条数据在主线程处理、渲染、筛选时会造成明显卡顿甚至页面崩溃业界常见解决方案
问题 常规处理方式 前端处理卡顿 使用 Web Worker 异步计算 页面卡顿、渲染慢 虚拟滚动(Virtual Scroll) 数据体量大 数据懒加载 / 分区渲染 / 分批计算 用户交互阻塞 增加 loading、节流、预加载机制等 我们的实际技术实现方案
全量数据加载: 后端返回最近 1 天的全部报警记录(控制在 10 万条以内)
异步筛选逻辑: 使用 Web Worker 独立线程对报警数据进行过滤(如张力超阈值、设备类型过滤、时间段筛选等),避免主线程卡顿
数据渲染: 使用 Element Plus + vue-virtual-scroll 组件自定义虚拟列表实现表格虚拟滚动渲染
交互优化: 支持本地模糊搜索、批量勾选标记已处理、本地导出 Excel、支持操作反馈动画和加载进度条
性能控制: 数据分页缓存至 IndexedDB,首次加载后切换筛选项不再重复请求后端实际落地效果
首屏加载时间 控制在 1~2 秒内完成全量数据加载
页面性能 即使处理 10 万条数据也无明显卡顿,用户操作流畅
客户反馈 用户满意度高,称“找异常数据非常方便”,后续多个模块参考复用此处理方案
项目稳定性 方案稳定运行超过半年,报警日志模块无重大性能投诉返回的数据结构:
以某台设备 “WINDER_012”(卷绕机)为例:
{
"deviceId": "WINDER_012",
"deviceName": "卷绕机 #12", // 设备标识与名称
"deviceType": "Winder", // 设备类型
"location": "生产线A区 第三工段", // 设备位置
"status": "运行中", // 当前运行状态
"lastUpdated": "2025-06-13T14:32:00Z",
"metrics": { // 当前监控指标
"tension": 3.8,
"tensionThreshold": 3.0,
"speed": 120.5,
"temperature": 27.5,
"humidity": 50.2,
"web_width": 3200,
"web_weight": 43.7
},
"alarmRecords": [ // 当前告警记录
{
"alarmId": "ALARM_103923",
"timestamp": "2025-06-13T14:30:00Z",
"alarmType": "张力异常",
"value": 3.8,
"threshold": 3.0,
"status": "未处理",
"description": "张力值超过设定阈值,可能影响卷绕均匀性"
},
{
"alarmId": "ALARM_103824",
"timestamp": "2025-06-13T10:12:33Z",
"alarmType": "温度波动异常",
"value": 29.0,
"threshold": 28.0,
"status": "已处理",
"description": "温度超过阈值,系统已自动调整"
}
],
"maintenanceHistory": [ // 最近的维修/校准记录
{
"date": "2025-05-12",
"type": "张力传感器校准",
"result": "正常",
"person": "李工"
},
{
"date": "2025-04-08",
"type": "设备保养",
"result": "已完成",
"person": "张工"
}
],
"operatorInfo": { // 当前负责的操作人员
"name": "王强",
"shift": "白班",
"contact": "139****7890"
},
"tags": ["高优先级", "需复检", "张力报警"]
}
系统为什么会“越用越卡”?
一般常见的原因有以下:
- 内存泄漏
长时间运行后内存不断升高, 最终导致卡顿甚至崩溃。
常见泄漏场景:闭包未释放, 事件监听未解绑, 定时器未清除, DOM引用未释放, 使用第三方库(如 ECharts、Three.js)时未销毁实例 - 频繁操作DOM: 重绘和回流频繁触发,造成卡顿
- 长列表未做虚拟滚动: 页面上有大量DOM节点未优化, 渲染压力大
- 资源未复用: 比如每次切换页面重新加载视频/图表/组件, 浪费性能
- 大文件加载或大量数据未做懒加载 / 分片加载: 一次性加载过多资源、卡顿严重
我们的解决方式一般如下:
复位与定位问题
我们首先使用 Chrome 的 Performance 和 Memory 工具分析卡顿原因,看是内存泄漏、JS 执行慢,还是渲染瓶颈
使用 Chrome DevTools 的:
- Memory 快照/Heap Snapshot 看是否有未释放对象
- Performance 面板看是否 JS 阻塞主线程或 Layout 太频繁
- Lighthouse 报告看 FPS 和帧率是否下降
通用优化策略
内存泄漏防护:组件销毁时移除事件监听、清除定时器、销毁实例
虚拟列表:使用 vue-virtual-scroller等虚拟滚动方案
数据加载优化:分页加载、懒加载、分片渲染, 列表数据分段加载,或一次只渲染首屏 + 滚动加载后续内容
降低频繁更新
debounce / throttle 防抖处理高频输入或滚动监听
图表/3D资源释放
对 ECharts、Three.js、video、canvas 等必须手动销毁资源
实际项目中的优化案例
示例:Three.js 场景越用越卡
在智能工厂项目中,3D模型运行 1 小时后内存明显升高。我们定位发现是模型和贴图未释放,手动调用了 dispose() 并断开引用,同时设置了定时器释放无用对象,优化后内存占用稳定。
示例:大表格数据展示卡顿
我们用虚拟滚动替代 Element UI 原始表格组件,数据量从 2 万条降到只渲染 30 条,滚动性能提升显著
总结
系统越用越卡本质上是内存占用或渲染负担在持续积累,我会先用 Chrome DevTools 定位具体问题,是内存泄漏、频繁渲染还是资源重复加载。然后根据问题采用对应优化策略,比如虚拟列表、资源懒加载、事件清理、图表销毁等。在我的项目中曾遇到类似问题,通过监控 + 释放机制优化,显著改善了长时间运行的性能。
项目有 50+ 个并发请求,如何处理
思路:
1.浏览器限制理解:
浏览器对同一域名的请求是有限制的(如 Chrome 最多同时 6 个 TCP 连接), 如果一口气发出 50+ 请求会阻塞 UI 渲染、部分请求 pending 很久、用户体验差(例如首屏数据延迟)
2.请求调度器方案:
- 限制最大同时发起 6 个请求,超过的排队等候
- 每个请求可以设置优先级,例如用户首屏区域的请求优先执行
- 请求完成后自动补充新的请求,实现动态调度,队列模式,完成一个补一个
3.“并发池 + 优先级队列”封装成可复用的 composable
useRequestScheduler.ts — 通用调度器
// src/composables/useRequestScheduler.ts
import { ref } from 'vue'
interface Task<T = unknown> {
fn: () => Promise<T>
priority: number // 数字越小优先级越高
resolve: (v: T) => void
reject: (e: any) => void
}
/**
* 并发受控 + 可优先级调度的请求池
* @param maxConcurrent 最大并发数,默认 6
*/
export function useRequestScheduler(maxConcurrent = 6) {
const running = ref(0) // 正在执行的任务数
const queue: Task[] = [] // 等待队列
function runNext() {
if (running.value >= maxConcurrent || queue.length === 0) return
const task = queue.shift()!
running.value++
task
.fn()
.then(task.resolve)
.catch(task.reject)
.finally(() => {
running.value--
runNext() // 填补空槽
})
// 填满空槽(当 queue 里还有任务且并发未达上限时)
if (running.value < maxConcurrent) runNext()
}
/**
* 向调度器插入任务
* @param fn 返回 Promise 的请求函数
* @param priority 优先级,默认 0;数字越小越先执行
*/
function addTask<T>(fn: () => Promise<T>, priority = 0) {
return new Promise<T>((resolve, reject) => {
queue.push({ fn, priority, resolve, reject })
// 按优先级从小到大排序
queue.sort((a, b) => a.priority - b.priority)
runNext()
})
}
return {
/** 运行中的任务数量,可做 loading 指示 */
running,
/** 添加任务并返回其 Promise */
addTask,
}
}
BigTable.vue — 大表格示例
<!-- src/components/BigTable.vue -->
<template>
<table class="w-full text-center border">
<tbody>
<tr v-for="(row, r) in data" :key="r">
<td
v-for="(cell, c) in row"
:key="c"
class="border p-2"
>
<!-- 简单展示:加载时显示 loading,中途可换成 skeleton -->
<span v-if="cell === null">⌛</span>
<span v-else>{{ cell }}</span>
</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { useRequestScheduler } from '@/composables/useRequestScheduler'
// 表格 10×10,可按需改成 50×50、100×100……
const ROWS = 10
const COLS = 10
/** 响应式二维数组,null 表示待加载 */
const data = ref<string[][]>(
Array.from({ length: ROWS }, () => Array.from({ length: COLS }).fill(null))
)
/** 创建调度器,最大并发 6 */
const { addTask } = useRequestScheduler(6)
onMounted(() => {
// 逐格添加网络请求
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
// ✔️ 这里示范优先级:首屏 / 靠前行列优先
const priority = r // 行号越小越靠前,优先级越高
addTask(
() =>
axios
.get(`/api/cell?row=${r}&col=${c}`)
.then(res => {
data.value[r][c] = res.data.value // 👈 根据返回结构调整
}),
priority
)
}
}
})
</script>
<style scoped>
/* 简单样式,可根据项目 UI 框架删改 */
table { border-collapse: collapse; }
td { min-width: 80px; }
</style>
频繁切换页码,导致页码和数据不对应?
取消上一个请求
// 用来保存上一次请求的“控制器”,它可以控制请求是否被取消
let controller: AbortController | null = null
function fetchPageData(page: number) {
// 取消上一次请求 abort()是 AbortController 的一个 方法,用于取消(中止)对应的请求。
if (controller) controller.abort()
// 创建控制器
controller = new AbortController()
// signal 是一个对象 控制信号 传给请求,让请求可被取消
const signal = controller.signal
axios.get(`/api/data?page=${page}`, { signal })
.then(res => {
data.value = res.data
})
.catch(err => {
if (err.name === 'CanceledError') {
console.log('请求被取消')
} else {
console.error(err)
}
})
}
请求响应标识 ID(requestId)
let latestRequestId = 0
function fetchPageData(page: number) {
const requestId = ++latestRequestId
axios.get(`/api/data?page=${page}`).then(res => {
if (requestId === latestRequestId) {
data.value = res.data
} else {
console.log('过期响应,已忽略')
}
})
}







