通过函数节流与函数分时提升应用性能

在例如表单自动补全,数据埋点,文章内容自动保存,视口监听,拖拽,列表渲染等高频操作时,如果同时有其它UI行为占据线程,浏览器端时常会出现卡顿现象,服务器端也面临着较大压力。这时,函数节流,与函数分时将成为我们的一大辅助。

一、函数节流

看一则自动补全的例子

//自动补全
const input       = document.querySelector('#autocompleteSearch'),
      completeArr = []; //需要被渲染的补全提示数据

input.addEventListener('keydown', e => {
    
    const value = e.currentTarget.value,
          xhr   = new XMLHttpRequest();
    
    
    xhr.addEventListener('load', ()=> {
        //请求完成后,将数据装载到数组中
        xhr.status === 200 && completeArr.push(xhr.responseText);
    });
    
    xhr.open('GET', 'http://api.com');
    xhr.send(value);
    
});

在这里,我没有提供具体的UI层的操作,只提供了触发事件时的handler,实际的开发中可能还需要涉及要补全数据数组的渲染逻辑以及排序和清除逻辑,但这并不妨碍我们理解本问题的过程。

可以看到的是,为了实时更新补全数据,每次当用户按下按键时,我们都要向服务器去发起一次请求。如果产品的用户基数很大,并发一高,那就实在是有些坑后端队友了。

回想需求,我们要根据用户输入的关键字像服务器索取补全的字段,反馈给用户快速选择。

实际上,在用户输入表单的过程中,可能按下很多次按键才会打出一个字,或者是打出了很多个字后,才能检索出真整的数据。

基于这个角度来换一下思路,如何限制请求的发送呢?

  • 判断value的长度,输入两个三个字以上,再向服务器发起请求
  • 将事件的handler触发频率降低

第一种思路,不失为是一种可行的方案,但是很难复用,而且用户真实想要搜入的字数并不确定。

第二种思路,既能限制频率,减少请求,还能近实时向用户反馈,无视用户输入的字符串长度,还可以实现高复用。

下面提供实现的方式,首先,实现函数节流:

const throttle =  (fn, time = 1000)=> {
    let triggered = true,        // 首次触发状态的标识
        timer;                  // 定时器触发标识
    return function () {
        if (triggered) {
            // 首次触发 回调直接执行
            fn.apply(this, arguments);
            //执行后 使首次触发标识为假
            return triggered = false;
        }
        if (timer) {
            // 定时器标识 如果为真 代表着之前的分流限制范围 尚未结束
            return false;
        }
        timer = setInterval(()=> {
            //如果定时器标识不为真 则为定时器赋上引用
            clearInterval(timer);
            // 取反定时器标识
            timer = !timer;
            // 执行回调
            fn.apply(self, arguments);
        }, time)
    }
    
};

上述代码,利用了闭包与高阶函数,限制了函数的触发,关键点在于首次触发与之前的节流是否结束的判断。

改造一下上面的自动补全代码。

const input          = document.querySelector('#autocompleteSearch'),
      completeArr    = [],
      keydownHandler = throttle(e => {
    
          const value = e.currentTarget.value,
                xhr   = new XMLHttpRequest();
    
    
          xhr.addEventListener('load',()=> {
              //请求完成后,将数据装载到数组中
              xhr.status === 200 && completeArr.push(xhr.responseText);
          });
    
          xhr.open('GET', 'http://api.com');
          xhr.send(value);
    
      }); //需要被渲染的补全提示数据

input.addEventListener('keydown',keydownHandler);


function throttle(fn, time = 1000) {
    let triggered = true,        // 首次触发状态的标识
        timer;                  // 定时器触发标识
    return function () {
        if (triggered) {
            // 首次触发 回调直接执行
            fn.apply(this, arguments);
            //执行后 使首次触发标识为假
            return triggered = false;
        }
        if (timer) {
            // 定时器标识 如果为真 代表着之前的分流限制范围 尚未结束
            return false;
        }
        timer = setInterval(()=> {
            //如果定时器标识不为真 则为定时器赋上引用
            clearInterval(timer);
            // 取反定时器标识
            timer = !timer;
            // 执行回调
            fn.apply(self, arguments);
        }, time)
    }
    
}

如此,实现了keydown事件触发的频率,当然,一些其他高频的事件回调依旧适合,我们可以根据具体的业务场景,来传入合理的time值,达到节流,既减轻了服务器端的压力,又提升了性能,例如上面的自动补全,1秒的延迟,用户几乎感受不到,何乐而不为呢?

二、分时函数

上面那种隐藏在用户操作背后,节流函数是一个很好的解决方案。同时,我们可能会面临另外一种场景,即是一次性渲染。

比如说,我们有这样的需求,后台给了我们2000行记录的数据,要一次性用列表全部渲染出来。

2000行数据可不是一个小数目,如果里面内嵌了很多子节点逻辑,那么很有可能我们也许要渲染上万个节点,众所周知,DOM可是浏览器环境性能的最大损耗者。为了提升用户体验与性能,通常情况下,我会使用两种操作。

  • 使用DOM的fragment,避免每次节点生成时的反复插入,可以在合理的时机向相应的节点插入,这方面的资料很多,可以自行查阅。
  • 使用函数分时 来分批处理渲染逻辑

先看如何在不分时的情况下操作节点:

const
    list        = document.querySelector('#ul'),
    virtualList = document.createDocumentFragment(), // 虚拟dom容器
    listArr     = [
        {text: 'hello react!'}
        // 假设这里有2000条记录
    ];

for (let i of listArr) {
    // 使用for of 遍历数据
    const li       = document.createElement('li');
    li.textContent = i;
    // 插入虚拟容器中
    virtualList.appendChild(li);
    
}

// 把载满节点的虚拟容器 插入到真实的列表元素中
list.appendChild(virtualList);

再来看分函数分时的实现:

function chunkFunc({fn, arr, count = arr.length, time = 200, sCb, aCb}) {
    
    /*
     * @params
     * fn   : 需要被分时的处理逻辑
     * arr  : 全部的业务数据
     * count: 每次分时的具体数量
     *        假设总共2000条数据
     *        我们可以设定
     *        每次分成200条执行
     *        默认为业务数据的长度
     * time : 分时的时间间隔 默认200 毫秒
     * sCb  : singleCallback 每次分时遍历结束时执行的回调
     * aAb  : allCallback 全部遍历结束时需要做的回调
     * */
    
    let
        timer,      // 用以分时的定时器标识
        start;      // 遍历处理逻辑
    
    start = () => {
        for (let i = 0; i < count; i++) {
            //如果count给了值 我们循环count次 每次循环都从业务数据里取值 然后执行处理逻辑
            fn(arr.shift());
        }
        //分时遍历结束 如果有回调 执行回调
        sCb && sCb();
    };
    
    return () => {
        // 默认每200毫秒执行一次
        timer = setInterval(function () {
            // 如果原始数据被取空了 则停止执行
            if (arr.length === 0) {
                aCb && aCb();
                return clearInterval(timer)
            }
            // 不然 执行遍历逻辑
            start();
        }, time);
    }
}

实现方式很简单,即根据用户给定的分时单位与时间,利用定时器重新包装用户处理逻辑,这里我们需要将渲染逻辑稍微改动,抽离出遍历逻辑,添加遍历结束回调方法(可选)。

重构代码如下:

const
    list    = document.querySelector('#ul'),
    listArr = [
        {text: 'hello react!'}
        // 假设这里有2000条记录
    ];

let virtualDOM = document.createDocumentFragment();


chunkFunc({
    fn(data) {
        // 生成节点逻辑
        const li       = document.createElement('li');
        li.textContent = data.text;
        virtualDOM.appendChild(li);
    },
    sCb() {
        // 分时遍历结束 将虚拟节点 插入LIST
        list.appendChild(virtualDOM);
        // 重置虚拟节点 避免重复生成节点
        virtualDOM = document.createDocumentFragment();
    },
    aCb() {
        // 最终结束后 解除引用
        virtualDOM = null;
    },
    arr  : listArr,
    count: 8,
    time : 300,
})();

通过抽离了插入、生成节点的逻辑、给出不同阶段的回调,我们成功的将本来需要一次性生成的节点,分批生成,提高了性能和用户体验。

结束语

在这里,我们虽然仅仅涉及了一些高阶函数应用的皮毛,但这两个技巧,实是项目开发当中克敌制胜,提高性能的实战利器。根据不同的业务场景伸缩,我们可以衍生出不同的方法。如果能结合单例模式,代理模式等常用的设计模式,将会有更为广扩的发挥。


Ives
29 声望1 粉丝

东北大汉一枚,粗通一些切图功夫。