问题由来
突然接到现场反馈,软件的模型树操作非常卡顿,客户要求立即排查解决问题。与客户沟通发现此问题存在于协同项目(另一种是本地建模的项目),且在项目包非常多的时候才会出现,并非所有项目都是如此卡顿。
尝试复现客户场景,建了几千个包。然后打开对应的协同项目,发现当滚动模型树,或者展开模型节点的时候非常卡,完全没法操作的。
开始排查
还好之前做过性能优化相关的,打开开发者工具:
- 切换性能面板,点击录制。
- 查看火焰图,发现一直有长任务执行。浏览器中长任务持续占用 JS 线程,因 JS 与渲染线程互斥,渲染线程无法工作,且阻塞事件循环,致使页面无法渲染、用户交互无响应。
- 选中对应执行任务( freshPackageStatus),查看摘要,点击来源中的文件地址,查看长任务的执行的地方。

经过排查发现这里是发起了一个 http 请求,接口返回了当前全量的模型树节点,然后做了一个逻辑处理,并会触发视图更新。最要命的是,这个请求还是前端轮询请求的。轮询的时间是 10s,但是从火焰图中可以看到,这里的长任务耗时是 16s 。所以整个页面处于完全卡死的状态。
解决办法
socket 消息通知
将轮询改为 socket 通知,我们项目本身就接入了 socket ,将轮询接口改为,当我收到指定的消息时候才去执行 http 请求。
这一步处理起来并不难,也确实解决了用户卡死的问题,但还有问题存在。当协同端触发消息通知的时候,前端还是会卡死一段时间,这个前端 16s 的逻辑执行时间并不会变短,只是减少了它触发的频率。
拆分长任务
当前采用的是分块处理和异步更新的方式,其中核心代码如下
ini
async processItemsInBatches(items, project:Project, batchSize = 200) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const packageStatus = {} as Record<string, CoworkPackageStatus>;
for (let item of batch) {
packageStatus[item.PACKAGE_ID] = { ...item };
}
project.setPackageStatus(packageStatus);
await new Promise(resolve => setTimeout(resolve, 0)); // 延迟一小段时间
}
}
分块处理的本质确实是利用事件循环机制来避免长时间阻塞主线程。JavaScript是单线程的,其执行模型基于事件循环:
主线程任务 → 微任务 → 渲染 → 宏任务 → 主线程任务...
当有大量同步代码执行时(比如forEach循环),会形成"长任务",分块处理通过setTimeout
或requestIdleCallback
将任务拆分为多个宏任务,让事件循环有机会在任务之间处理其他事件和渲染。
batchSize 设置的合理性

可以根据不同的设备计算出合适的 batchSize ,我这边就图方便设置了一个固定的大小。

经过优化后可以看到每个任务都被拆分
现在的执行流程是
- processItemsInBatches 分块处理任务
-
- setPackageStatus 更新视图状态
- 触发视图更新
再近一步优化视图更新

发现有很多重复的计算,并且组件渲染时间比较长。
主要问题如下:
- 每次遍历都去对比 newStatus 和 packageStatus 造成不必要的计算。
- status 本身是一个很大的对象,每次对比都是一个比较耗时的计算,叠加第一条一起就造成了大量的重复的 js计算。
相关逻辑代码如下
ts
const newStatus = {}
result.data?.forEach(item => {
newStatus[item.PACKAGE_ID] = {
...item,
};
project.setPackageStatus(newStatus);
});
setPackageStatus(status: { [p: string]: CoworkPackageStatus }) {
if (!this.isPackageStatusSame(this.config.onlineConfig.packageStatus, status)) {
this.config.onlineConfig.packageStatus = status;
// ...
}
}
isPackageStatusSame(status1:{ [p: string]: CoworkPackageStatus }, status2:{ [p: string]: CoworkPackageStatus }) {
const keys = Object.keys(status1);
if (keys.length !== Object.keys(status2).length) {
return false;
}
for (const key of keys) {
if (!status2[key]) return false;
const old = status1[key];
const newItem = status2[key];
if(条件判断) {
return false;
}
}
return true;
}
- 树节点组件依赖 packageStatus 状态, 一棵树可能几千上万个树节点,上面的 setPackageStatus 是重新修改了 packageStatus 的引用,导致每个节点组件的 computed 函数都要重新计算一遍。如果状态发生变更的话需要重新更新节点视图。
kotlin
computed: {
packageLockedIcon() {
const { id } = this.graphNode.data;
const packageStatus = this.graphNode.project?.config.onlineConfig?.packageStatus;
if (packageStatus && packageStatus[id] && packageStatus[id].LOCKED) {
return true;
}
return false;
}
}
解决思路
- 构建完成 newStatus 再去做更新
ini
const newStatus = {}
// 构建 newStatus
result.data?.forEach(item => {
newStatus[item.PACKAGE_ID] = {
...item,
};
});
// 完成后再去做更新
project.setPackageStatus(newStatus);
- 优化对比算法,来比较对应模型相关的数据是否一致 3. 避免直接修改 packageStatus 对象引用,可以增量的变更/删除对象相关的属性
经过上面的优化后大大减少 js 计算的时间,以及渲染相关的时间。效果如下图:

总结
排查问题思路
- 通过performance定位主要性能卡点,主要为10s一次的定时器请求及回调函数中有长任务
- 通过socket解决定时器轮训状态问题
- 定位长任务中原因为频繁触发大对象的比对。
- 合并比对次数,以及优化比对算法。
- 最终减少了大多数的js计算的时间,并且减少了视图渲染次数。