给力星

Web Developer

Mac/Chrome中滚动加载产生卡顿的原因研究与解决方案

在工作中参与了一个 Web IM 项目,遇到了这样一个情况:使用鼠标滚动加载历史内容,有时出现明显卡顿,需要 3~5 秒才能完成加载。折腾许久,才总算定位了原因所在,也给出了一个不那么完美的解决方案,在此记录思路,以供碰到相似问题的人参考。

情况描述

遇到的情况是这样的: 在对话界面的消息面板里,向上滚动拉取历史消息有时会很慢,需要 3s~5s 才能完成加载,但此时后端数据请求几百毫秒内就返回了数据。而且,这个现象只在 Mac 电脑的 Chrome 浏览器里才会有,Windows 电脑的 Chrome 浏览器就不会,Mac 电脑的 Safari、Firefox 浏览器也不明显,实在奇怪,难以捉摸,值得研究,到底是什么原因产生了延时?

从 Chrome dev tool 中的 network 可以看到这样的现象:

拉取历史消息慢的情况--间隔请求拉取历史消息慢的情况--间隔请求

可以看到,在 2.2s 左右发起 options 请求,但是到 5.5s 左右才发起相应的 post 请求,中间有明显的间隔(这个一开始误导了我,并不是 Chrome 处理 options 请求的 bug),而从 Ajax 的执行情况来看,也确实在请求发起的几秒后,才触发 success 回调。从下面这张动图,可以更好地看清发生的情况:

拉取历史消息慢的情况--长时间pending拉取历史消息慢的情况--长时间pending

可以看到,请求长时间处于 pending 的状态,另外还有一个细节,请求的完成时间会跳动,如 post 请求的完成时间从 110ms 最后变成了 106ms (截图不好截,有时可以看到 option 请求的时间从 3s 多到最后变成 100ms,猜测这个 time 应该只统计请求的时间,Chrome 在请求完成时会自动纠正时间,去掉不属于网络请求的时间开销,因此才产生了图中的请求间隔情况。如果是请求方面的问题,会体现在 Timeline -> stalled 里)。

由此可以得出结论,就是该 pending 导致了拉取历史消息慢,那究竟是哪个环节导致了 pending?

问题原因

经过数天折腾,最终定位到问题原因所在:滚动操作。移动端的滚动性能可能会是个问题,没想到 PC 端居然也会,甚至还影响到了网络请求。寻找原因的过程中我是走了不少弯路的,有兴趣的可以看看文章后面我的折腾记录。

我们可以基于如下现象来断定该问题确实是滚动操作引起的:

  • 使用代码 document.querySelector('#msglist .load-more').click(); 来触发加载,均不会产生间隔请求的情况;
  • 使用滚动加载的方式,会导致间隔请求的情况;
  • 若使用代码方式触发加载,并且在加载期间操作滚动条,会产生间隔请求的情况。

还有一点,如果使用 Mac 的触摸板,滚动操作带有一个平滑的 east-out 效果,加载慢也会变现更加明显,会达到 5s 左右,如果使用鼠标,则是 3s 左右。

解决方法

在一番实践后,我安装了一个插件 Chromium Wheel Smooth Scroller,发现拉取消息慢的情况大大改善了,基本上控制在了 2s 左右。这个插件其实就是更改了滚动操作,那顺着这个思路能否解决该问题?试试看就知道。

自定义Wheel事件

自己实现滚动操作,可通过 scrollTop 来模拟滚动,而解决该问题最主要的是要通过 e.preventDefault(); 取消滚动的默认操作,以如下代码简单进行尝试:

var container = document.querySelector('#msglist .comp-dynamic-loader');
function wheelHandler(e) {
    container.scrollTop += e.deltaY;
    e.preventDefault();
}

container.addEventListener('wheel', wheelHandler);

执行该代码后,加载时间从 5s 左右可以控制在 3s 以内,还是有一定效果的。

最终方案

经过多次折腾,实在是难以找到更多导致该情况的本质原因,并且,一旦伴随着滚动操作,该情况都是无法完全避免的。
考虑到在正常操作的情况下,拉取历史消息慢的情况只会出现在 Mac/Chrome 环境下,因此最终采取的方案是不对当前的加载组件做任何改动,而只是给组件增加了一个自定义 wheel 事件即可,代码如下(React):

// ...
var isMac = /macintosh|mac os x/ig.test(navigator.userAgent);
var isChrome = /chrome/ig.test(navigator.userAgent);
wheelHandler(event) {
        // 解决Mac/Chrome环境下, 拉取历史消息慢的问题
        if (isMac && isChrome && this.props.name === 'loadHistoryMessages') {
                event.preventDefault();
                if (this.refs.wrapper.scrollTop === 0 && event.deltaY <= 0) {
                        return false;
                }
                this.refs.wrapper.scrollTop += event.deltaY;
        }
}
// ...
<div className={dyClassName} ref="wrapper" onScroll={this.onScroll} onWheel={this.wheelHandler}>
    {trigger}
    {children}
</div>
// ...

经过这样简单的处理后,拉取历史消息的耗时基本控制在了 1.5s 以内,大多时候都是在 1s 内,而之前普遍需要 3s~5s 左右的时间,效果十分之显著,可算是解决了该诡异的问题(虽然没能从本质上解决问题)。

折腾记录 — 原因研究

一开始认为该问题是 options 请求引起的,因此走了不少弯路。网上搜索不到什么有用的信息,有相似的一些讨论,但是是由 cache 引起的 bug,不适合本问题。想自己通过修改请求、修改返回内容来试验,但又没有后端权限,怎么搞?还好有 Charles 这个软件,提供了 Map Remote 功能,也就是请求重定向,因此我可以在自己的服务器上放一个页面,然后将拉取历史消息的请求重定向到该页面,就可以随心所欲地修改想返回的内容了。

通过后端请求的日志,可以看到确实在 options 请求发起后的 2s 左右,才会发起 post 请求。但进行了如下尝试,均没有效果。

  1. 设置 Access-Control-Max-Age,设置该 header 后,可使接下来一段时间的跨域请求不必再发起 options 请求
  2. 去掉请求的自定义 header,完全避免跨域请求
  3. 将 POST 请求改为 GET 请求
  4. 修改返回的 headers,如返回 Content-Length
  5. 修改返回的聊天内容为空、减少返回的聊天内容

在拉取历史消息时,会同时发送一个性能统计的请求,难道是并行请求的 bug ?使用 然而也不是。

后来了解到 Chrome 中访问 chrome://net-internals/#events,可以分析网络请求的相关信息,但也没有分析出问题原因来。

后端没问题,就只能前端找原因了。虽然上述 “修改返回的聊天内容为空、减少返回的聊天内容” 的方法无效,但隐约发现,使用鼠标点击“加载更多”不会有间隔情况,但滚动加载就会,感觉胜利就在前方。

于是,尝试使用代码 document.querySelector('#msglist .load-more').click(); 来触发加载,均不会产生间隔请求的情况,而使用滚动加载的方式就会出现。此外,如果使用代码触发加载,并且在加载期间操作滚动条,也会产生间隔情况,由此可以断定该问题确实是滚动操作引起的。

折腾记录 — 解决方案

  1. 研究 Chrome 的 Scroll 机制:访问 chrome://flags/ 可以看到一些 Chrome 的实验性功能,尝试关闭线程式滚动(threaded-scrolling)特性(线程式滚动:对与滚动相关的输入事件进行线程式处理。停用此项后,所有此类滚动事件都将在主线程中处理),问题依旧;
  2. wheel 事件改为 passive 的处理方式:在滚动加载过程中,有时会出现一条提示 “Handling of ‘wheel’ input event was delayed for 132 ms due to main thread being busy. Consider marking event handler as ‘passive’ to make the page more responive.”,这是浏览器级别的功能,将 wheel 处理函数改为 ‘passive’ 模式,可以优化 scroll 的性能,但经过测试,并没有预期的效果,而且也就延迟了 100多毫秒,并不是原因所在;
  3. 删掉所有滚动事件:尝试删掉了Web大象中的所有滚动事件,然后以滚动+手动点击加载的方式,问题依旧;
  4. 删掉页面中其他元素:尝试删掉页面中其他元素,只保留一个聊天窗口,避免干扰,但没有效果;
  5. 自己建了一个瀑布流页面进行测试:尝试建了一个简单的瀑布流页面,测试滚动加载是不是一定会导致这种情况,但测试中发现没有出现这种情况,也难以得出结论;
  6. 自定义 Wheel 事件:将鼠标滚动操作转换为操作元素的 scrollTop,有一定的效果,5s 左右的加载时间降低到了 3s 内,但还不能从根本上解决;
  7. 自定义 Wheel 事件 + 删除滚动事件:在自定义 Wheel 事件的基础上,将滚动事件删除,以滚动+手动点击加载的方式,可以解决间隔请求的问题。

参考资料

发表评论

电子邮件地址不会被公开。