innerHTML的性能问题

最近进行了一次前端渲染性能优化,遇见iOS中webview的innerHTML性能问题,总结一下。

<!--more-->

1. 问题场景

1.1. 业务背景

公司的商品大促页面采用Hybird实现,通过后台配置动态生成多个会场页面,由web前端负责渲染数据,在大促期间,运营一般会配置一百多个页面,每个页面大概有几百个到几千个商品进行展示。

出现的bug是:在iPhone手机上,在两个模板页面之间,来回点击次数过多,就会出现cpu暴涨到99%的情况,导致整个APP直接黑屏闪退。

由于历史原因,我们的iOS客户端使用的仍旧是UIWebview容器。在搜索时发现了类似BUG,提到升级到WkWebview就能够解决。但由于时间和项目的关系,仍旧需要前端进行排查。

1.2. 问题分析

原始问题:使用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的弹窗执行

    setTimeout(() => {
          alert('alert from test2.html')
    }, 1000);
  • 在test2.html页面编写渲染逻辑代码,模拟大促模板渲染逻辑

    / 每个模块每个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的数量)来修改页面渲染的复杂度,数字越大则越容易出现闪退

1.3. 初步结论

由于我们模板页面模块的处理方式是每个模块先请求对应的数据,然后遍历数据渲染节点,拼接html字符串,然后通过innerHTML插入到模块容器上。

经过上述分析和测试步骤,初步得出结论 :在运营配置的模板页面过于复杂时(如模块过多,商品数量过多时),在切换到上一页时,由于webview容器没有及时释放web页面的资源,导致CPU及内存占用过高,导致页面闪退,CPU的资源占用的地方有

  • innerHTML,innerHTML会创建一个HTML解析器,效率高于手动调用createElement等方法,效率提升带来的问题是需要消耗大量的CPU,这在需要解析的html字符串过长时更为明显
  • 每个模块的渲染都是等待接口返回,异步执行渲染的,可能存在在某个时间段内,多个模块连续调用innerHTML的情况,导致CPU占用时间过长;此外还会导致页面频繁的reflow等操作

2. 相关问题

下面是在搜索及思考这个性能问题时,遇见的一些问题,一起记录下来。

2.1. 修改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是否是异步的问题

2.2. innderHTML的性能问题

一般地,我们有两种在页面上创建DOM节点的方法:

  • 使用诸如createElement()appendChild()之类的DOM方法。
  • 使用innerHTML直接插入html字符串

一般来说,使用innerHTML的效率要高于手动调用createElement等方法。当使用innerHTML设置为某个值时,后台会创建一个HTML解释器,然后使用内部的DOM调用来创建DOM结构,而非基于JAVASCRIPTDOM调用。

由于内部方法是编译好的而非解释执行,故执行的更快。 对于小的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占用。

2.3. 修改的html字符串过长时效率降低

参考

文章给出的解决方案是

function replaceHtml(oldEl, html) {  
    var newEl = oldEl.cloneNode(false);  
    newEl.innerHTML = html;  
    oldEl.parentNode.replaceChild(newEl, oldEl);  
    return newEl;  
}; 

但是这个问题的是大概是十年前提出来的了~

3. 优化方案

再来回顾下问题分析。

大促模板实现方案是在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占用,避免频繁切换页面时的资源浪费。

4. 小结

上面的bug只有在特殊场景(复杂页面)及特殊操作(2s内频繁切换页面)才会复现。由于电商业务的特殊性,需要运营灵活地配置展示页面,导致出现了上面的问题。经过上面的修改后,由于模块加载的节流,频繁页面的2s中,只加载了前面的模块,降低了页面的性能消耗,勉强解决了性能问题,但还存在下面一些需要优化的地方

  • 每个模块的复杂程度也不一致,节流的频率应该根据模块的复杂度动态控制,在保证性能的前提下尽快完成页面的渲染,避免正常用户(非频繁切换)的浏览体验
  • 如果对多个模块数据渲染后的html进行拼接,然后按分段插入页面,这样可以减少innerHTML的调用次数。
  • 上面问题的主要原因是由于客户端的webview容器没有回收上一个页面的资源,如果iOS客户端升级WKwebview容器,应该可以彻底解决该问题