1.背景
公司内部的一套单据系统,由于页面表单头和表体嵌套明细数据量较大而且逻辑复杂,怕逻辑互相影响,最开始设计使用一个详情页面打开一个tab新页签。系统一直稳定运行。 由于系统需要商业化,对整体的交互性能与速度要求做大幅度的提升。
优化前
- 详情编辑页面由于是整体保存,每次只改动一个输入框都要把整体提交和整体重新渲染。单次保存+渲染5秒。
- 详情编辑页面由于使用tab新页面打开,每次打开tab浏览器都要重新渲染进程,并且每次打开都需要重新加载vue框架,第三方库等,首屏加载到渲染达3秒多。
优化前-浏览器多页签方案
- 每个一个页签都是独立的分配的内存和资源
- 不同页面都是独立维护,不互相影响。
优化后
- 通过标记对每一行数据做新增,修改,删除的标记,做到精准按需提交改变的内容。同时保存后后端也只返回变化的数据给前端,实现保存0.5秒。
- 通过升级http2多路复用,开启gzip,cdn分发,dns预解析,各种压缩,简化代码依然无法达到秒开的效果,最终决定废弃tab新页面的方式,回归到经典的单页面模式,使用vue的keep-alive 配合自定义的内置页签实现和动态路由。实现了原来的3秒到1秒内的性能优化。
优化后-浏览器单页签+vue页签方案
- 只有一个页签,只有一个页签的资源
- 所有vue组件实例共用一个页签的资源,会互相影响
2.现实
当首次上线时,产品和业务部门都十分满意,而且性能得到了70%的提升。 本以为是大团圆结局,没想到是噩梦的开始....
陆陆续续收到用户反馈“喔唷,崩溃啦!”,用户体验一下暴跌。
当看到奔溃的画面,不知道大家的心情是否会和页面一样奔溃。
3.问题处理
行动1
分析
- 刚开始遇到反馈时,本能反应是自己的代码写了 while死循环之类的代码。检查一轮也没发现,之前elementUI select组件传入NaN会奔溃也以为是哪里的问题,但是屏蔽代码后,依然有用户偶尔反馈。
- 由于用户都是使用一段时间才反馈奔溃,所以开始怀疑是 内存泄露的问题。开始使用谷歌浏览器-开发者工具-的Memory和性能监视器分析问题(参考这篇)。果然打开的详情页面没有正常的释放内存。但是当所有页签都关闭的时候又可以正常释放。
通过performance -> memory 看到当前内存使用情况,
- 通过疯狂的打开内部页签+关闭,发现内存已经达到惊人的2g,相关的操作已经开始无法响应,页面卡顿甚至白屏
方案
直接使用performance.memory.usedJSHeapSize 监听当前的内存大小,当超过自定容量如 1GB,提示用户关闭所有页签释放内存,同时限制用户打开页签的数量。
效果
很不理想,不稳定,有时候全部页面关闭也不能立马释放内存,而且当用户不关闭的时候,内存不足的提升会一直显示。由于内存会泄漏,限制一个页签,重复打开关闭依然会导致奔溃
行动2
分析
通过资源和代码进行分析
- 检查是否加载的大量的视频或图片。
- 被全局的变量或者函数,组件卸载时未清除
- 被全局的事件,或者定时器引用着,组件卸载时未清除
- 自定义事件引用,组件卸载时未清除
方案
- 优化的图片与视频资源,减少图片转base64加载显示,还原为
加载,并对图片进行压缩
- 移除了window上挂载组件内部的变量的逻辑
- 第三方键盘事件确实一直引用着当前组件,在组件卸载时进行了清除。
- 维护多页签的集合也引用着各个组件的引用,在组件卸载时进行了清除。
- 部分工具类传入了组件的实例,在组件卸载时进行了清除。
- 跨组件传递对象改成copy新对象方式
效果
很不明显,问题依旧,内心再次奔溃
行动3
- 由于内存对自己是黑盒子,加上详情页面交叉了很多逻辑也有可能导致内存溢出。所以只能从原始的基础功能开始一步步验证到底是那个环节导致内存溢出。
分析 + 方案
- 搭建纯 vue + vue-router + keepalive + 动态路由的项目 配合chrome的memory分析,具体参考:vue中keepalive的内存问题 通过修改vue2.16.4源码,解决keepalive 引用问题,确保的组件能够实时释放。
效果
怀着愉快的心情,把最新vue库整合到项目里,发布后问题依旧,用户电脑上依然存在奔溃现象,只是发生的时间相比之前长一点,心情也再度奔溃。
行动4
- 行动3只是修复了简单场景的vue引用释放问题。当你引入vue的组件内存引用问题会更加严重。并且无法有效解除引用。
分析 + 方案
由于主要是每开一次都重新实例了vue,所以最好的办法就是全部共用一个vue实例。每次切换直接修改数据源。
- 每次打开页签 每个页签都生成viewCode 唯一key
- 并且把当前页签的内容data 和 viewCode 通弄过 map的集合 保存到当前业务页面的vue实例里
- 每次切换通过 map 去索引对应的内容data,并进行加载
- 关闭页签 则从map中通过viewCode 移除 内容data
伪代码
const map = {};
let __isDEL = false; //标识当前是否删除
export default {
beforeRouteUpdate(to, from, next) {
next()
// 路由切换触发页签加载
this.multiTabLoad(to, from)
},
deactivated() {
// 页面离开进行页签状态判断处理
this.multiTabCache(this.viewCode)
},
activated() {
// 页面被激活触发页签加载
this.multiTabLoad()
},
methods: {
multiTabLoad(to, from) {
// 这里处理同页面切换过来的逻辑 只要是支持多页签的页面就先缓存数据
},
multiTabCache(code) { //进行map缓存
// 如果这个页面数据被标记删除就不走缓存的逻辑
if (this.__isDEL) return
// 通过viewcode直接拷贝当前页面的所以数据
map[code] = {
...this.$data
}
},
multiCloseCache(viewCode) { // 进行map移除
// 进入清理逻辑 先打标识数据要被删除不要再走缓存的逻辑
// 做 map移除操作
}
}
效果
由于只每次只加载一个vue业务实例,所以在多次打开页签关闭的请求下。也只会实例一个实例,内存里只保存渲染的data的数据,大大减少了的内存的使用。
行动5
分析
排除掉自身代码和官方库问题后,剩下的可能就是浏览器的机制问题了。
- 根据用户反馈,查看用户电脑,发现内存有些只有4g,浏览器可用内存其实是依赖于操作系统,所以配置低的用户更容易复现问题,
- 同时一个关键点是V8的垃圾回收机制是系统自动触发的,虽然官方说是在用户空闲时候进行,但是实际测试,即使鼠标键盘都不交互干等一分钟,浏览器中的V8也未必会实时的回收。尤其是在页面包含复制的嵌套逻辑,v8可能出于性能考虑延迟对他的回收。
- 由于用户使用的是快捷键操作,会重复创建详情页面,又关闭详情页。导致内存瞬间飙升。在垃圾回收未触发时,已经导致内存超过可用空间直至奔溃,
方案
所以关键是在于如何强制执行,在每次关闭页面就离开触发垃圾回收。
上网搜下,发现有一个window.gc()方法,但是死活提示不存在。细查才知道要在浏览器启动时进入参数,才能在全局上下文访问这个方法。
在浏览器启动的快捷键,设置参数Chrome --js-flags="--expose-gc" ,这样全局的window上就多了gc()的方法。当然缺点也很明显就是所有的用户都配置一下启动的参数。
还好是内部的toB系统,用户数也不大,并通过跟运维同事商量帮忙为用户统一配置chrome启动参数。
当然由于频繁的关闭就回收会带来性能的损耗,所以加入了达到一定阀值,再进行回收,比如说超过500m再进行回收。
效果
效果很ok,内存能正确回收,每次当页面关闭时超过指定的一个阀值,都能主动触发垃圾回收,确保用户的业务不中断。nice~~
4.总结
对于页面奔溃,我们要分两种情况,
- 一点击就奔溃
- 用一段时间后,在任何地方都可能奔溃。
- 第一种好解决,主要是调用栈溢出,代码能快速定位,也大概率是自己的代码逻辑死循环问题。
- 第二种就不好搞,因为是内存溢出,这是一个不断累积增加的过程,在大型项目里尤其难快速定位。
我们可以借助chrome的开发工具来分析定位
- chrome的performance 诊断报告中 memory信息
- chrome的memory 诊断报告 + 主动垃圾回收
- chrome的performance monitor实时监测
在代码排查分为
- 检查是否加载的大量的视频或图片。
- 被全局的变量或者函数,组件卸载时未清除
- 被全局的事件,或者定时器引用着,组件卸载时未清除
- 自定义事件引用,组件卸载时未清除
- 变量嵌套引用
- 排查第三方库
- 排查浏览器垃圾回收
每一个架构都有他存在的意愿,这次技术升级中,老的架构使用传统多的tab实现,虽然效率比单页面内置页签要低。但是他又符合了web的架构设计思想(一个页面一个tab)并且每开一个tab浏览器都会单独分配独立的内存资源,这也是为什么早期web不会频繁出现内存奔溃的所在。当我们在追求性能的提升同时,也要全面考虑系统的其他指标,如内存与cpu运算效率等,客户端过渡渲染与工程化确实会导致后面越来越多的瓶颈。我们应该要在CSR和SSR中找到一个平衡点。