项目总结

权限

菜单权限

账号登陆

传递账号、密码、时间戳、验证码
请求图形验证码 要传递参数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.合并切片,上传完毕通知服务器切片上传完了,让其合并切片,完成上传

代码拆分

  1. 自定义 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>
  2. 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;
    };
  3. 将切片的数据进行维护成一个包括该切片文件,切片名的对象(切片编号)

    // 一个个切片组成的数组和文件名称
    const transformChunkFileList = (chunkFileList: Blob[], name: string) => {
      return chunkFileList.map((chunkFile, index) => {
        return {
          chunk: chunkFile, // 切片文件
          hash: `${name}-${index}`, // 切片名
        };
      });
    };
  4. 上传切片(切片上传)

    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);
    };
  5. 上传完所有切片,通知服务器合并上传切片成一个文件(合并切片)

    const mergeRequest = (name: string) => {
      return request({
        url: "http://localhost:3000/merge/file",
        headers: { "content-type": "application/json" },
        data: JSON.stringify({
          filename: name,
        }),
      });
    };

显示上传进度

  1. 上传之前,初始化上传进度

    // 代表已经上传的文件大小
    file.loaded = 0;
    // 让进度条开始显示
    file.status = "uploading";
  2. 上传切片的每个请求添加 onprogress

    // 当这个请求上传中会触发 onprogress
    xhr.upload.onprogress = onProgress;
  3. 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)
      );
    }
  4. 当上传总进度达到 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 的名称保存起来

恢复上传

  1. 携带 hash 参数发送请求给服务器,获取该文件是否上传过;
  2. 如果上传过,无需上传(实现秒传);
  3. 如果只上传了一部分,获取上传的一部分切片名称,前端上传时跳过这些已经上传切片,这样就实现了“续传”的效果;(过滤)
  4. 如果没有上传过就直接全部上传即可。
    后端两个地方 一个是专门用来传递的 一个是存起来的切片文件夹

完整代码

<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%。

懒加载与预加载结合:
视频卡片滚动进入视口时再加载视频(懒加载)
在浏览器空闲时预加载视频的第一段分片(预加载)
思路:

  1. 先封装一个视频卡片组件。你在一个视频列表页面,会渲染很多这样的卡片。ref=”cardRef”给整个卡片绑定一个引用,后面用来监听是否出现在屏幕上,v-if=”!visible”:如果还没进入屏幕,显示一张图片;v-else:如果已经进入视口了,才显示 <video> 视频标签, preload=”metadata”:只加载元数据(而不是整个视频),节省资源
  2. 懒加载:监听是否进入视口,使用 IntersectionObserver 监听这个卡片元素是否“出现在可视区域”,一旦进入视口,设置 visible.value = true,显示视频,停止监听(disconnect())
const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    visible.value = true
    observer.disconnect()
  }
})
  1. 预加载首个分片:
    这部分在浏览器“空闲时”做预加载:通过 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大屏适配

不管用户用多大分辨率的屏幕,我们的图表都能按比例缩放,完整显示在屏幕中间,并且不被裁剪。
我们会根据浏览器窗口的宽高,去和设计稿的宽高做一个比例比较,然后按照这个比例整体缩放页面内容。这样图表就不会变形,也不会溢出。

  1. 大屏适配的核心是通过 父盒子相对定位子组件绝对定位 的方式,将内容居中展示,并通过 scale 动态调整大小,实现适配不同分辨率的屏幕
  • 父盒子设置 position: relative;,为子组件提供定位上下文。
  • 子组件设置 position: absolute;,通过 transform 实现居中显示,同时利用 scale 按比例缩放内容。
  1. 封装适配组件
  • 将适配逻辑封装成 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(颜色、字体大小)、itemWidthitemHeight(图例宽高)、itemGap(图例间距)。)、tooltip(提示框trigger: 'axis',提示框在鼠标悬浮到轴上时显示)、grid(设置 topleftrightbottom,保证图表在容器中合理分布。

自动布局: 开启 containLabel,避免图表内容与边界重叠 )、xAxisyAxisseries(数据系列 柱子宽度,颜色,label柱子上面的数字显示) 等部分按照 ECharts 的标准配置进行模块化拆分,方便阅读和修改。

提供高度自定义的样式设置(如颜色、字体、间距等),满足不同业务需求。

二次封装axios

  1. 携带统一的请求前缀 + 超时时间
  2. 进度条的添加(也可以在路由守卫去做)
  3. 返回的响应不再是响应,而是响应里面的数据data
  4. 统一处理请求错误,也可以选择后续继续处理或者不处理
    发送请求,携带公共请求参数:token
    返回响应,根据 code / status 判断请求成功还是失败
  • 成功返回成功的数据
  • 失败提示失败的准确原因

取消重复请求

  1. 每个请求根据请求的配置项生成一个相应 key(相同的请求生成的 key 相同,不同的请求生成的 key 不同)
  2. 发送请求的请求拦截器中,判断当前请求是否在请求列表容器中存在,存在即取消上一个请求,axios取消请求的cancelToken使用
  3. 如果当前请求在请求列表容器中不存在,需要将其存储起来
  4. 请求完成,在响应拦截器中需要将当前请求给删除

取消上一个页面的请求

  1. 存储请求时,需要将取消请求的方法和当前路由路径一起储存
  2. 当路由跳转时,判断请求列表中的请求地址和要去的路由地址是否时同一个,如果不是,就要取消

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

移动端适配方案

  1. rem 适配方案:使用 rem 单位代替 px,通过动态设置根元素 的 font-size 来实现不同屏幕下的等比例缩放。可搭配 postcss-pxtorem 插件自动把 px 转 rem。
      // 基于设计稿 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);
    })();
    
    缺点:对设计稿要求统一(比如统一使用 375px 或 750px);
  2. viewport 适配方案(百分比布局 + meta viewport)
    通过设置 viewport 的缩放比例 + 使用百分比、vw/vh 单位进行布局,使页面在不同设备下显示一致。
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    使用 vw / vh 单位开发页面:
    .container {
      width: 100vw; /* 屏幕宽度 */
      height: 100vh; /* 屏幕高度 */
    }
    或者使用百分比配合媒体查询、flex 等布局方式。
    总结:

    移动端适配主要有 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并渲染到页面
  1. 数据来源: 这些设备每天 24 小时不间断运行,每台设备每秒产生约5-8个关键数据点(如张力、速度、温湿度、产线状态等),一个中大型无纺布工厂通常有至少50台主要设备在运行, 主要设备包括:梳理机,铺网机,水刺机,卷绕机,烘干机等

  2. 生产管理人员提出:

    “我想快速筛出张力异常的报警记录,按照设备类型和时间段进行过滤,并支持多选勾选后批量标记已处理或导出。”

虽然后端提供了分页接口,但客户反映说:

“每次要点击下一页去找异常记录太麻烦,有时候根本记不得在哪页,能不能一次性加载出来、我本地筛一下?”

  1. 数据规模
    在我们智能工厂项目中,报警记录数量取决于设备数与报警频率,日报警达到几万甚至10万是有可能的,尤其是在多传感器监控张力、温湿度、速度等指标下
    一个工厂有 50 台设备,每台设备如果每小时有 20 条报警,如果报警规则非常敏感(比如张力波动 ±0.1 都算报警)
    每台设备每天报警:20 × 24 = 480 条
    50 台设备总报警:480 × 50 = 24,000 条

  2. 面临的问题
    后端分页返回数据,无法支持“全局模糊搜索”“跨页筛选”
    客户强调“需要一次性加载+快速筛选+批量操作”
    10 万条数据在主线程处理、渲染、筛选时会造成明显卡顿甚至页面崩溃

  3. 业界常见解决方案

    问题 常规处理方式
    前端处理卡顿 使用 Web Worker 异步计算
    页面卡顿、渲染慢 虚拟滚动(Virtual Scroll)
    数据体量大 数据懒加载 / 分区渲染 / 分批计算
    用户交互阻塞 增加 loading、节流、预加载机制等
  4. 我们的实际技术实现方案
    全量数据加载: 后端返回最近 1 天的全部报警记录(控制在 10 万条以内)
    异步筛选逻辑: 使用 Web Worker 独立线程对报警数据进行过滤(如张力超阈值、设备类型过滤、时间段筛选等),避免主线程卡顿
    数据渲染: 使用 Element Plus + vue-virtual-scroll 组件自定义虚拟列表实现表格虚拟滚动渲染
    交互优化: 支持本地模糊搜索、批量勾选标记已处理、本地导出 Excel、支持操作反馈动画和加载进度条
    性能控制: 数据分页缓存至 IndexedDB,首次加载后切换筛选项不再重复请求后端

  5. 实际落地效果
    首屏加载时间 控制在 1~2 秒内完成全量数据加载
    页面性能 即使处理 10 万条数据也无明显卡顿,用户操作流畅
    客户反馈 用户满意度高,称“找异常数据非常方便”,后续多个模块参考复用此处理方案
    项目稳定性 方案稳定运行超过半年,报警日志模块无重大性能投诉

  6. 返回的数据结构:
    以某台设备 “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": ["高优先级", "需复检", "张力报警"]
}

系统为什么会“越用越卡”?

一般常见的原因有以下:

  1. 内存泄漏
    长时间运行后内存不断升高, 最终导致卡顿甚至崩溃。
    常见泄漏场景:闭包未释放, 事件监听未解绑, 定时器未清除, DOM引用未释放, 使用第三方库(如 ECharts、Three.js)时未销毁实例
  2. 频繁操作DOM: 重绘和回流频繁触发,造成卡顿
  3. 长列表未做虚拟滚动: 页面上有大量DOM节点未优化, 渲染压力大
  4. 资源未复用: 比如每次切换页面重新加载视频/图表/组件, 浪费性能
  5. 大文件加载或大量数据未做懒加载 / 分片加载: 一次性加载过多资源、卡顿严重

我们的解决方式一般如下:
复位与定位问题
我们首先使用 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+ 个并发请求,如何处理

你有一个表格,里面每个单元格都要请求接口,一次性产生了 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>

频繁切换页码,导致页码和数据不对应?

取消上一个请求

使用 AbortController 取消上一个未完成的请求,确保只保留最后一次的请求

// 用来保存上一次请求的“控制器”,它可以控制请求是否被取消
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)

请求发出时附带一个递增的 requestId,返回时比对该 ID,如果不是最新的请求,则忽略数据渲染,只有当前最新的请求才会更新页面数据

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('过期响应,已忽略')
    }
  })
}