在腾讯文档表格中,如果用户打开表格的内容非常多,比如有几万行或者几十万个单元格。内存占用会居高不下,在使用的过程中非常容易出现崩溃,卡顿等问题,在进行一系列的优化之后,写下这篇文章,总结下期间用到的一些优化方案,希望可以或多或少帮助或者启发一下他人。
怎么找出内存问题
在内存这里,我们一般是借助 Google 浏览器的开发者工具,然后获取下页面的内存快照,然后分享具体占用过大内存的对象。
大型前端项目内存优化总结 - 图1
大型前端项目内存优化总结 - 图2
除了查看内存占用过大的对象之外,也可以切换到 Containment 这里查看一些占用内存过大的全局对象。
大型前端项目内存优化总结 - 图3

常见的内存优化方案

按需加载

按需加载可能是能够想到的最简单的内存优化的方式,比如当某一个功能没有使用的时候,不加载这个功能模块,也不进行初始化。举个例子,比如帐号管理模块,在没有点击右上角头像部分的时候,是不需要加载的,这个时候就可以改成按需加载。
大型前端项目内存优化总结 - 图4
在腾讯文档表格的优化中,部分改造为按需加载的模块使用率:

  1. Account 组件改成按需加载 用户使用率:0.8%
  2. QuickFill 组件改成按需加载 用户使用率:1.2%

优化了这些模块之后,内存占用大概降低了 10M 左右,优化效果并不明显。老板说:
image.png
image.png

对象池

对象池模式在初始化的时候会创建固定数量的缓存,并且每个对象都可以复用,对象没有更新的需求。
image.png
对象池这种模式主要用于游戏或者数据库连接池等场景。因为腾讯文档表格优化的时候没有使用到对象池的方式,这里就不详细描述了,有需要的可以自行 Google。

享元

享元模式的特点:

  • 属性全部只读或者私有化
  • 提供创建对象实例的工厂方法
  • 修改时重新创建对象实例
  • 使用 key 缓存对象,key 相同直接返回缓存对象实例

举个例子:假设目前有三个变量 ABC,引用的都是同一个内存中的对象。
大型前端项目内存优化总结 - 图8
假设这个时候需要修改对象 A 的属性,则需要使用新的属性参数,创建一个新的对象,讲 A 指向新创建的对象,此时内存当中的对象应该是这样的:
大型前端项目内存优化总结 - 图9
这个时候,内存当中就有两个对象了,BC 还是指向的之前的对象,但是这个时候的 A 已经指向新创建的对象了。

下图是在腾讯文档表格中,部分使用享元模式优化之后的内存对比,可以发现优化效果还是比较明显的。
image.png

享元遇到的问题

以为用享元就能解决问题了?
image.png
直到看到这个图:
大型前端项目内存优化总结 - 图12
用户在操作设置数据格式之后,前端提交到数据后台,落盘失败!

大型前端项目内存优化总结 - 图13
在做享元优化的时候,遇到一个特殊的问题,这里也记录下,方便大家后面使用享元的时候注意。我们有一个对象,用来描述单元格的值,以及值的类型。如下所示:
大型前端项目内存优化总结 - 图14
type 表示类型,value 就是存储的值。

type 的类型有如下几种
image.png
这个对象的 key=type+,+value

image.png
假设此时实用如下参数创建一个对象:
{ type: 1, value: 123,}
此时生成的 key=’1,123’
很明显,这个时候的 value 和 type 的类型对应不上,但是由于我们没有任何校验,导致错误的创建了一个对象,并且以 key=’1,123’ 被缓存起来了。
当下次创建的一个对象的时候,传入如下的参数:
{ type: 1, value: ‘123’,}
这里的参数,跟上面的区别是 value 和 type 是对应上的,但是注意,这个时候生存的 key=’1,123’。嗯?发现这个 key 在之前已经创建过了,并且缓存了一个对象。这个时候,不会再去创建一个新的对象,而是将上面的对象直接返回给到使用方,然后报错了。
这个情况在腾讯文档表格中就出现过,有的业务模块创建了临时的 ExtenedValue 对象,在传参数的时候,类型和值对应不上,导致数据层在提交数据的时候报错。
那如何解决这个问题呢? 在创建 key 的时候,获取到 value 的真实类型,将真实类型作为 key 的一部分,这样,上述的两个参数创建的就会是两个不同的对象了。

进阶版内存优化方案

对象存储优化

一个 demo

大型前端项目内存优化总结 - 图17
上图中的代码,占用内存 4.8M。
image.png
上图中的代码,占用内存 3.6M。

image.png
对比两者的内存快照发现相差的就是 1.2M。这是为什么呢?

对象在 v8 中的存储方式

大型前端项目内存优化总结 - 图20
首先来看下对象在 v8 中的存储方式,在 v8 中,js 的对象使用 JSObject 对象来描述。这个对象有下面几个主要属性:

  • HiddenClass 用来存储对象属性存储的位置信息
  • properties 用来存储具名属性
  • elements 用来存储有顺序的属性,类似数组 在这种情况下,存储在 properties 中的属性,都是快属性模式(使用数组的形式存储)。

那针对上面的问题,猜测可能是因为
this.a = null
初始化属性 a 的时候,HiddenClass 的结构会变化,从而增加内存。接着使用 v8 的调试工具 d8 来验证下:
image.png
大型前端项目内存优化总结 - 图22
从上面的 debug 信息中可以看出,初始化的时候,如果赋值为 null,会在 HiddenClass 的 DescriptorArray 中添加记录,用来描述属性 a 的初始值。 所以,在初始化属性的时候,如果参数没有传递该属性的值,那就不要初始化为 null,因为这样会占用内存。

优化验证

针对上述的情况,我们优化了腾讯文档表格的单元格对象 CellData 的初始化过程。
大型前端项目内存优化总结 - 图23
可以发现,优化的效果非常明显。30w 单元格的表格,CellData 的内存占用从 24M 减少到了 12M。

优化到到这里的时候,老板再问我能达到预期目标的时候:
大型前端项目内存优化总结 - 图24

清除 delete 操作

一个 demo

有 delete 操作

image.png

image.png

没有 delete 操作

大型前端项目内存优化总结 - 图27
大型前端项目内存优化总结 - 图28
从上面的图中可以看出,没有 delete 操作会比有 delete 操作的内存占用少非常多。这又是为什么呢?

v8 HiddenClass 的转化过程

v8 在初始化对象,给对象添加属性的时候,会变更 hiddenClass 指针的指向,将其指向最新的节点。先看一个 v8 官方网站的图:
大型前端项目内存优化总结 - 图29
在这个图中,当我们给对象 o 添加属性的时候,hiddenClass 的指针一直在变化,也就是说每次给对象添加新的属性,都会生成一个新的 HiddenClass 节点,所有的 HiddenClass 节点组成了一个的链表(其实准确的说应该是 tree ),然后对象中的 hiddenClass 指针指向最新的节点。

上面说的所有的 HiddenClass 节点其实组成了一棵树,在 v8 里面叫做 transition tree。例如:假设这个时候,新创建一个对象,并且添加了一个新的属性:
image.png
这样看是不是就知道为什么是一棵树了。

那这些跟 delete 操作有什么关系呢?

delete 操作导致 v8 的对象存储模式退化为字典模式

当对属性进行 delete 操作的时候,会将上面所说的链表结构打破,并且对象在 v8 内的存储方式也会发生变化。变成如下所示的结构:
大型前端项目内存优化总结 - 图31
或者直接看 v8 官方网站提供的图:
大型前端项目内存优化总结 - 图32
可以看到,如果你对一个对象的属性进行 delete 操作,就会导致对象的存储方式退化到字典模式(慢属性模式)。相对于之前的快属性模式,这种存储方式更加消耗内存。所以这也是为什么 delete 操作会导致对象内存占用增加的根本原因。

验证:
const obj = { a:1, b: 2,}%DebugPrint(obj);delete obj.a;%DebugPrint(obj);
大型前端项目内存优化总结 - 图33
删除属性 a 之后,退化到字典模式:
image.png
再看一下这个图,我们发现在 HiddenClass 节点也有一个 back pointer ,指向上一个节点。
image.png
所以,如果你是按照对象添加属性的反方向删除属性的话,对象并不会退化到慢属性模式,或者对象的内存占用并不会增加。这里更多详细的描述可以参考 v8 的官方文档或者 superzheng 大佬在知乎的专栏。

如何替换掉 delete 操作?

赋值给 undefined

假设有如下代码:
const data = { a: ‘1’, b: ‘c’,}// do something…_delete data.c;
如果没有特殊的要求,建议直接修改为:
const data = { a: ‘1’, b: ‘c’,}
// do something…data.c = undefined;
使用如下代码测试:
const startTime = +new Date(); for (let i = 0; i < 1000000; i++) { const a = { a: ‘1’, b: ‘2’, c: ‘3’, };
// a.b = undefined;_ delete a.b; } const endTime = +new Date(); console.log(endTime - startTime);
赋值给 undefined 比直接 delete 删除,要快几十倍,甚至上百倍。

使用 Map 替代

假设你的对象必须就是要删除,那么还可以使用 Map 来替代对象。
const data = { a: ‘1’, b: ‘c’,}// do something…_delete data.c;
可以修改为如下的形式:
const data = new Map();data.set(‘a’, ‘1’);data.set(‘b’, ‘c’);
// do something…// 使用 Map 对象提供的 delete 方法删除元素_data.delete(‘a’);

性能监控(保护优化成果)

内存监控

内存优化的手段固然重要,但是如果在优化之后一段时间,随着需求的增加,由于一些新代码的引入,或者写代码时候不注意,就有可能导致内存暴增。这个时候,就在想是否能够在发布之前,就可以自动化的检测到腾讯文档的特征表格的内存占用,然后跟现网环境的内存占用对比。如果发现内存占用比现网的要高很多,则可以在发布阶段终止发布,接着进入代码 review 阶段,分析哪里导致的内存占用增加。解决了问题之后,再重新进行发布流程。
针对这个自动化测试,腾讯文档这边目前也实现了一个可以自动化获取页面内存快照和大小的工具,可以接入到流水线当中,作为一个性能检测的方式集成到发布流水线当中,保护内存优化成功。

参考资料

**

最后,如果你对性能优化感兴趣,腾讯文档欢迎你的加入。
关于AlloyTeam

AlloyTeam 是国内影响力最大的前端团队之一,核心成员来自前 WebQQ 前端团队。
AlloyTeam负责过WebQQ、QQ群、兴趣部落、腾讯文档等大型Web项目,积累了许多丰富宝贵的Web开发经验。

这里技术氛围好,领导nice、钱景好,无论你是身经百战的资深工程师,还是即将从学校步入社会的新人,只要你热爱挑战,希望前端技术和我们飞速提高,这里将是最适合你的地方。

加入我们,请将简历发送至 alloyteam@qq.com,或直接在公众号留言~