如何计算TTI

TLDR

  • TTI指的是用户与UI界面的可交互时间点,它无法用浏览器的API直接侦测到。
  • TTI的关键指标与longtasknetwork-statusFMP以及quite window有关系。
  • 利用请求代理以及浏览器MutationObserver综合计算TTI。
  • 5S内首页变得可交互不失为一种好体验。
  • 尽可能快地让网页变得可交互。

TTI

TTI(Time To Interactive)用来标识用户对界面的可操作时间,与其他性能指标一样用来衡量web界面的相关性能。目前为止浏览器提供了一个Performance.timing.domInteractive的api来衡量此项指标。但TTI和其他指标一样,是个变量,我们不会知道代码获取运行时界面是否可以达到交互的状态。因此还是需要借助回调的方式最终得到结果。虽然我们无法用PerformanceOberserver 或者Performance两个方法获取此项指标,但是需要综合各个api对其进行测量。

定义

首先我们先明确定义,用户可交互,说明界面大部分的内容是可见的,并且交互是顺畅的,视觉上不存在长时间的卡顿,交互事件的JS加载并且执行。基于以上提到的这几点,我们可以推断出TTI与一下几点指标有关:

1. Longtask

由于Javascript在V8引擎中通过事件循环单线程的调用栈里面执行,如果有任务执行过长,响应的事件可能会排在队列里面等待太久,界面上的表现就是gif图片或者动画无法播放,用户交互事件无法响应,现象上的表现就是感觉卡顿。这样当然就算不上是一个可交互的界面。

2. In-flight network

未完成的网络请求:包含XHR,静态资源等HTTP请求。网络进程与渲染进程独立,应该不会阻塞js的执行,但是因为开启达到了TCP通道的上线,从而阻塞了XHR或者脚本的下载,从而减缓了界面的渲染和交互,所以才把这些作为衡量TTI的因素去考虑。

3. Quite window

空闲窗口期指的是在network进程和render进程中不再执繁琐的任务,即两个或者以下的正在进行中的请求以及0个长任务。这样的时期,我们可以定义为空闲时期,在这段时间里面的任何时候,用户可以很顺畅地与界面进行交互。这个指标就是第一点和第二点的集合。

4. Fisrt Contentful Paint

以FCP为基准点,开始长任务的监听。为什么不以navigationStart为基准点?因为界面一片空白的时候,就算没有longtask,TTI计算就没有意义了。所以要以界面渲染可见为基准点,这个时候用户是可以看到界面的,并且会尝试去与之交互。如果没有长任务,说明FCP就是TTI的时间:即界面出现即可交互,这样的体验无疑是极好的。

现在我们通过一张图,查看TTI的相关指标计算模型:

2.png

实现

有了定义,我们就可以开始用代码去实现了:

  1. Longtask较好实现,有浏览器提供的PerformanceOberserver,传入longtask参数即可在一个长任务结束后获取到该改任务的执行时间和开始执行时间。我们可以用一个长任务的结束时间为一个里程节点,开始或者结束一个状态。
//我们讲其封装一下
const Observer = (type: string, callback: HanderPerformanceFn): PerformanceObserver | undefined => {
    try {
        //判断是否支持该参数选型的监听
        if (PerformanceObserver.supportedEntryTypes.includes(type)) {
            const observer: PerformanceObserver = new PerformanceObserver(list => {
                list.getEntries().forEach(callback);
            });
            observer.observe({ entryTypes: [type] });
            return observer;
        }
    }catch (ex) {
        return undefined;
    }
}
//监听长任务结束事件
Observer('longtask', (longtask: HandlePerformanceLongTask) => {
    //计算长任务结束时间
    const longtask = longtask.duration + longtask.startTime;
});

Longtask可以侦听在主线程上长任务结束的事件,长任务是指那些运行时间超过了50ms的任务,我们可以在开发者工具中看到它们的存在,下面这张图中右上角标有红色三角形的就是长任务了。
4.png
一般来说它们可以是:

  1. 包含大量计算的脚本任务。
  2. 花费很久界面的绘制任务。
  3. dom解析任务也可能花很多时间。
  4. 还有就是糟糕的css代码导致样式计算耗时。

下面这张图中的每一个过程,都可能产生长任务,影响界面。
1.png
我们通过浏览器提供的API,在field环境下做到当一个长任务结束时,响应回调时间,此时计时器开始计时,在约定的时间段内如果后面没有新的Longtask执行,我们可以把最后一个Longtask结束的时间点定义为。关键是我们如何去衡量这段时间。我们一般用的是5s的,后面我们会继续说明这段时间的算法和改进。

  1. Network的监听则相对较复杂,要监听HTTP的发送和接收状态,而浏览器本身并没有这个api给我们。所以,为了要监听所有的HTTP请求状态,我们必须通过代理的方式对XHR,Fetch方法以及静态资源进行监控。拆分出来总共分三步:
    • 通过代理XMLHttpRequest.send的方法监听所有的XHR:
//代理XMLHttpRequest对象的send方法
const proxyFetch = () => {
    const send = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function(...args) {
    //为每一个请求简历唯一的id标识 
    const requestId = uniqueId++;
    beforeXHRSendCb(requestId);
    this.addEventListener('readystatechange', () => {
        //监听完成的状态
        if (this.readyState === 4) onRequestCompletedCb(requestId);
    });
    return send.apply(this, args);
};
  • 通过fetch代理的方法监听fetch所有的请求,有时候应用程序并非使用的ajax,而是通过fetch这个新特性来发送请求。因此,我们需要对他进行代理侦听:
function patchFetch(beforeRequestCb, afterRequestCb) {
    const originalFetch = fetch;
    fetch = (...args) => {
        return new Promise((resolve, reject) => {
        //同样的,我们也为每一个fetch创建一个唯一的id
        const requestId = uniqueId++;
        beforeRequestCb(requestId);
        originalFetch(...args).then(
            (value) => {
                afterRequestCb(requestId);
                resolve(value);
            },
            (err) => {
                afterRequestCb(err);
                reject(err);
            });
        });
    };
}

有了数据请求的状态控制,资源文件的控制相对要难一些。资源文件并非通过以上方法来从服务器获取信息。因此,需要另寻出路。还要,有浏览器提供了MutationObserver这个接口出来。MutationObserver算是一个新的浏览器特性,它是用来替代古老的Mutation event types以及setTimeout轮训hack的。它运用了微任务这种非阻塞型任务类型对dom的变化集合进行侦听。我们这里就可以用它来解决问题。这篇文档能帮助你对它有一个粗略的了解。现在,我们回到正题,如何用MutationObserver接口监听资源请求的转态:

  • 首先,列出需要监听的静态资源列表:
    const requestCreatingNodeNames =
        ['img', 'script', 'iframe', 'link', 'audio', 'video', 'source'];

然后利用MutationObserver的方法对资源的变化进行监听,从而起始知道当前的资源状态变化。后面通过PerformanceOberserver 中的 resource的方法来检测他们的完成状态。


function subtreeContainsNodeName(nodes, nodeNames) {
    for (const node of nodes) {
        if (nodeNames.includes(node.nodeName.toLowerCase()) ||
            subtreeContainsNodeName(node.children, nodeNames)) {
        return true;
        }
    }
    return false;
}

function observeResourceFetchingMutations(callback): MutationObserver {
    const mutationObserver = new MutationObserver((mutations) => {
    mutations = /** @type {!Array<!MutationRecord>} */ (mutations);
        for (const mutation of mutations) {
            if (mutation.type == 'childList' &&
                //递归判断节点是否为目标节点
                subtreeContainsNodeName(
                    mutation.addedNodes, requestCreatingNodeNames)) {
                callback(mutation);
            } else if (mutation.type == 'attributes' &&
                requestCreatingNodeNames.includes(
                    mutation.target.tagName.toLowerCase())) {
                callback(mutation);
        }
        }
    });
    //监听整个文档
    mutationObserver.observe(document, {
        attributes: true,
        childList: true,
        subtree: true,
        //需要监听的属性 href src 包含所有的静态资源
        attributeFilter: ['href', 'src'],
    });

    return mutationObserver;
}

有了计算监听http请求的方法,我们就可以创建一个http资源池,我们把这个资源池比作一个水池,一个资源标识1L的水量。我们的目的是找到这样一个时间点:池子的水正好小于3L,并且保持在这水位以下达5s的时间。

    //资源池
    let pool: number[] = [];
    //定时器
    let timer: null | number = null;

池子里的水流入流出,对应于资源的开始和结束。我们可以通过上面的代理函数,监听资源的开始和结束。为每个资源建立一个id。

    //流入
       poll.push(uintId++);
       if(poll.length < 3)
            timer = setTimeout(() => {
                alert('got it!');
            }, 5000)
        else
            clearTimeout(timer);
    //流出
       poll = poll.fliter(item => item !== unitId);
        if(poll.length < 3)
            timer = setTimeout(() => {
                alert('got it!');
            }, 5000)
        else
            clearTimeout(timer);

一个资源算1L的水,如果水少于3L的时候,我们每次都重启一个5s倒计时,当水量大于3L的时候,我们停止倒计时,等待下次再重新倒计时。如此反复,直到水小于3L的时间大于5s。我们就算捕获到了一个Quite window。通过空窗期和最后一个longtask结束的时间反推出TTI的时间。

3.png

以上是web.dev上关于TTI定义的代码的实现。你也可以通过lighthouse在本地评估你的网页的TTI时间,lighouse集成了lab环境下的几乎所有性能指标,比用代码实现要香很多。

评估&优化

小于5s的TTI时间是最好的,如果FCP就是TTI那就是极好的。当然,你的FCP也要足够快。下面Google文档中提供的一些优化方法,也补充说明了部分条目。

  • Minify JavaScript 压缩脚本体积

webpack或者rollup这些构件工具可以自动帮你做这些事情。

  • Preconnect to required origins 预先链接,一般设计到多界面跳转时用到。> 通过设置meta标签中的配置,我们可以与连接源目标,减少dns等查询时间。

<link rel='dns-prefetch' href='your-web-site-address' />

  • Preload key requests 预先加载资源

在meta中设置link的rel属性为preload,提示资源加载的优先级这点很容易做到。

  <!-- 代码插入到head中,浏览器会优先加载列表资源 -->
  <link rel="preload" href="style.css" as="style" />
  <link rel="preload" href="index.js" as="script />
  • Reduce the impact of third-party code 减少第三方组件的影响

如果无法避免第三方的脚本引入,请采用npm的方式,并且通过构建工具合并,压缩优化

  • Minimize critical request depth 关键资源最小化> 采用构建工具的code split,用惰性方法加载资源,代码分片。合理安排内容渲染出来所需的脚本大小。

  • Reduce JavaScript execution time 减少js的执行时间

我们可以参考facebook对react的升级改造,采用reconciliation减少主线程的工作任务量。

  • Minimize main thread work 较少主线程的工作量

减少主线程的工作量。将含有大量计算的量分散个worker等协程去做。或者使用GUI分层渲染技术。will-change, transfrom: tanslateZ(0).

  • Keep request counts low and transfer sizes small 少发请求,请求体积不能过大。

http1.x有很多毛病,将你的应用升为http2,多路复用的特性能给你带来质的飞跃。注意解决拥塞窗口的问题也能提示你的应用打开的速度。

更多

还有一篇文档是讲如何侦测FI和FCI的,这篇文档相对于我们上面讲到的要复杂一些,我把它翻译出来,有需要的同学可以去查看。总得来说,web场景的复杂度远不是一种指标和定义能去衡量的,应该因地制宜,多而全得去评估各项指标。你可以参考我的这篇文章来结合一起看看性能评估都有哪些标准。

总结

最后我们来总结一下这篇文章的一些要点:

  1. TTI是指用户与界面的可交互时机,它无法直接通过浏览器的api检测到。
  2. 四种影响TTI的指标,这些指标都有着自己的合理性。
  3. 我们用JS技巧和浏览器提供的DOM监听机制,实现了一个HTTP请求状态的侦听者,从而判断出空闲窗口期的时间。
  4. 最后我们列出lab环境下的测量方法,以及一些优化它的方法。

参考文档

  1. Time to Interactive (TTI)
  2. First Interactive and Consistently Interactive
原文地址:https://www.cnblogs.com/constantince/p/15423516.html