innerHTML的性能问题
最近进行了一次前端渲染性能优化,遇见iOS中webview的innerHTML性能问题,总结一下。
问题场景
业务背景
公司的商品大促页面采用Hybird实现,通过后台配置动态生成多个会场页面,由web前端负责渲染数据,在大促期间,运营一般会配置一百多个页面,每个页面大概有几百个到几千个商品进行展示。
出现的bug是:在iPhone手机上,在两个模板页面之间,来回点击次数过多,就会出现cpu暴涨到99%的情况,导致整个APP直接黑屏闪退。
由于历史原因,我们的iOS客户端使用的仍旧是UIWebview容器。在搜索时发现了类似BUG,提到升级到WkWebview就能够解决。但由于时间和项目的关系,仍旧需要前端进行排查。
问题分析
原始问题:使用iPhone在两个web页面,通过 前进<->返回操作 来回点击次数过多,就会出现cpu暴涨到99%的情况,导致应用直接黑屏闪退
原因分析:项目中iOS应用使用的是UIWebview,经过查询和测试,发现进入页面并立即返回上一页时,当前页面的代码逻辑(如alert、innerHTML等操作)仍在执行,频繁切换页面会可能导致网页占用的内存和CPU资源占用未被释放,进一步导致应用直接闪退
测试步骤:
本地新增两个测试页面test1.html和test2.html,启动一个本地服务器,并保证路由能够访问到两个测试页面(切换到对应文件目录,php -S localhost:9999 ),由于APP网页存在白名单,需要使用nginx配置一下代理
在测试页面导入客户端协议代码,支持使用客户端协议
loadpage
,编写逻辑代码,保证能从test1.html点击跳转到test2.html(注意这里不是location.href
跳转)频繁操作:test1.html点击跳转到test2.html -> test2.hteml点击左上角返回按钮返回test1.html -> test1.html点击跳转到test2.html
在test2.html页面足够简单的情况下,重复操作不会导致应用崩溃,可以在test2页面编写一下代码,返回足够快时可以在test1.html看见test2.html的弹窗执行
jssetTimeout(() => { alert('alert from test2.html') }, 1000);
在test2.html页面编写渲染逻辑代码,模拟大促模板渲染逻辑
js/ 每个模块每个deal的模板字符串 var html = `<div>很长很长的html...</div>`; // 页面上配置了30个容器 var $modules = $(".wrap"); var repeatTime = 200; // 每个容器配置200个元素 $modules.each(function (index) { var element = $(this); var rows = [] for (var i = 0; i < repeatTime; ++i) { rows.push(html) } // 使用innerHTML,插入拼接后的模块字符串 element.html(rows.join('')); })
复现上述闪退问题,可以通过调整$modules的数量,以及repeatTime(每个模块下deal的数量)来修改页面渲染的复杂度,数字越大则越容易出现闪退
初步结论
由于我们模板页面模块的处理方式是每个模块先请求对应的数据,然后遍历数据渲染节点,拼接html字符串,然后通过innerHTML
插入到模块容器上。
经过上述分析和测试步骤,初步得出结论 :在运营配置的模板页面过于复杂时(如模块过多,商品数量过多时),在切换到上一页时,由于webview容器没有及时释放web页面的资源,导致CPU及内存占用过高,导致页面闪退,CPU的资源占用的地方有
- innerHTML,innerHTML会创建一个HTML解析器,效率高于手动调用createElement等方法,效率提升带来的问题是需要消耗大量的CPU,这在需要解析的html字符串过长时更为明显
- 每个模块的渲染都是等待接口返回,异步执行渲染的,可能存在在某个时间段内,多个模块连续调用innerHTML的情况,导致CPU占用时间过长;此外还会导致页面频繁的reflow等操作
相关问题
下面是在搜索及思考这个性能问题时,遇见的一些问题,一起记录下来。
修改DOM到底是同步的还是异步的
这里有
使用这个例子
<ul>
<li id="i0"></li>
<li id="i1"></li>
<li id="i2"></li>
<li id="i3"></li>
<li id="i4"></li>
</ul>
<ul id="newEle"></ul>
<script>
for(var i = 0;i<5;i++){
var item = document.getElementById('i'+i);
item.innerHTML = i;
}
var newEle = document.getElementById('newEle');
for(i=0;i<5;i++){
var li = document.createElement("li");
li.innerHTML = i;
newEle.appendChild(li);
}
</script>
如果修改DOM是异步的,则类似于setTimeout,五个li元素中应该都插入5(循环结束后才执行异步队列),实际上我们看到的是1-5这五个数。通过反证法可以证明修改DOM实际上是同步操作。
正确的结论是:修改DOM是同步的,但是渲染是异步的。
这里突然想到之前遇见控制台consonle.log
输出的结果跟代码预测输出不一致的问题,可以移步这里:console.log是否是异步的问题。
innderHTML的性能问题
一般地,我们有两种在页面上创建DOM
节点的方法:
- 使用诸如
createElement()
和appendChild()
之类的DOM
方法。 - 使用
innerHTML
直接插入html字符串
一般来说,使用innerHTML的效率要高于手动调用createElement
等方法。当使用innerHTML
设置为某个值时,后台会创建一个HTML
解释器,然后使用内部的DOM
调用来创建DOM
结构,而非基于JAVASCRIPT
的DOM
调用。
由于内部方法是编译好的而非解释执行,故执行的更快。 对于小的DOM
更改,两者效率差不多,但对于大的DOM
更改,innerHTML
要比标准的DOM
方法创建同样的DOM
结构快得多。
下面这段代码可以明确看到二者性能的比较
console.time('innerHTML');
var ul = document.createElement('UL');
var ih = '';
for (var i = 0; i < 100000; i++) {
ih += '<li>' + i + '</li>'
}
ul.innerHTML = ih;
document.body.appendChild(ul);
console.timeEnd('innerHTML'); // 243.818115234375ms
console.time('createDocumentFragment');
var ul = document.createDocumentFragment();
for (var i = 0; i < 100000; i++) {
var p = document.createElement("p");
var oTxt = document.createTextNode("段落" + i);
p.appendChild(oTxt);
ul.appendChild(p);
}
document.body.appendChild(ul);
console.timeEnd('createDocumentFragment'); // 504.03369140625ms
需要注意的是:上面提到的问题只是innerHTML的效率比较高,随之带来的问题是CPU资源消耗也是比较严重的,使用innerHTML插入超长文本时占用的CPU资源十分巨大,这在移动设备上表现的更加明显。
可以通过新版chrome的Performance面板,查看这两段函数的CPU占用。
修改的html字符串过长时效率降低
参考
文章给出的解决方案是
function replaceHtml(oldEl, html) {
var newEl = oldEl.cloneNode(false);
newEl.innerHTML = html;
oldEl.parentNode.replaceChild(newEl, oldEl);
return newEl;
};
但是这个问题的是大概是十年前提出来的了~
优化方案
再来回顾下问题分析。
大促模板实现方案是在node的view层获取模板配置,生成模块容器DOM节点,并埋入每个模块渲染需要的数据。在完成加载页面时,会遍历模块并加载对应的模块渲染函数。
所有模块的逻辑函数都是同步执行的,在需要商品详细数据的接口会先调用接口请求数据,并在回调中处理模块的渲染逻辑,调用innerHTML渲染到页面上。
由于在进入页面初始化时,会同时发送多个网络请求(浏览器可能存在同一个域名的请求并发上线),并在请求回调中处理数据,频繁调用innerHTML修改各自模块容器。
此处可以优化为:**通过节流函数控制每个模块的渲染间隔,页面靠后的模块间隔一定时间后再执行加载渲染逻辑,**这样可以避免同一时间多个网络请求发出,并在某个很短的间隔内同时处理多个模块的渲染逻辑,错开innerHTML的渲染高峰期。
此外,在进入页面就立即离开的频繁操作中,取消掉未完成渲染模块的请求和渲染(通过nativeSDK.calljs.goback注册原生方法,需要客户端支持),可以节省相关资源的开销。
下面是实现的伪代码
var isRendering = true;
// 离开页面时修改标志符,清空当前页面,释放内存
// 在频繁切换页面时可以避免还未开始渲染的模块继续渲染
nativeSDK.calljs.goback = function () {
isRendering = false;
document.body.innerHTML = '';
return true;
}
$modules.each(function (index) {
var element = $(this);
// 此处省略加载模块渲染函数...
// var module = require(moduleName)
function render(){
module.call(null, element, moduleData[id]);
}
// 首屏元素仍旧保持同步渲染
if (index < 3) {
render()
} else {
setTimeout(() => {
// 如果离开页面,则不进行后续模块的渲染
if (isRendering) {
render()
}
}, index * 100);
}
})
通过控制innerHTML的调用频率,可以显著地降低进入页面时的CPU占用,避免频繁切换页面时的资源浪费。
小结
上面的bug只有在特殊场景(复杂页面)及特殊操作(2s内频繁切换页面)才会复现。由于电商业务的特殊性,需要运营灵活地配置展示页面,导致出现了上面的问题。经过上面的修改后,由于模块加载的节流,频繁页面的2s中,只加载了前面的模块,降低了页面的性能消耗,勉强解决了性能问题,但还存在下面一些需要优化的地方
- 每个模块的复杂程度也不一致,节流的频率应该根据模块的复杂度动态控制,在保证性能的前提下尽快完成页面的渲染,避免正常用户(非频繁切换)的浏览体验
- 如果对多个模块数据渲染后的html进行拼接,然后按分段插入页面,这样可以减少innerHTML的调用次数。
- 上面问题的主要原因是由于客户端的webview容器没有回收上一个页面的资源,如果iOS客户端升级WKwebview容器,应该可以彻底解决该问题
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。