JS笔记(5) 闭包、高阶编程技巧及防抖节流

347 阅读15分钟

一、闭包的概念:

闭包不是具体的代码,而是函数运行的一种机制

函数执行会形成一个私有的上下文,如果上下文中的某些内容(一般指的是堆内存地址)被上下文以外的一些事务(例如:变量/事件绑定等)所占用,则当前上下文不能被出栈释放[浏览器的垃圾回收机制GC所决定的] => "闭包"的机制:形成一个不被释放的上下文

函数执行形成一个私有的上下文,此上下文中有私有变量,和上下文中的变量互不干扰;也就是当前上下文把这些变量保护起来了,我们把函数的这种保护机制称之为闭包。

市面上很多人认为,形成的私有上下文很容易被释放,这种保护机制存在时间太短了,不是严谨意义上的闭包,他们认为只有形成的上下文不被释放,才是闭包,而此时不仅保护了私有变量,而且这些变量和存储的值也不会被释放掉,保存起来了

  • 保护:保护自己的私有变量不被外界干扰
  • 保存:上下文不被释放,上下文中的私有变量及值都会被保存起来,可以供其下级上下文中使用 利用这两种机制,可以实现高阶编程技巧
  • 函数套函数,子函数使用父函数的参数或者变量

  • 并且子函数被外界所引用,此时父级形成闭包环境

  • 父级的参数或者变量不被浏览器垃圾回收机制回收.

  • 此时,打印父函数的返回值,有个属性为Scopes

  • Scopes下有个closure的属性,closure 就是闭包。

  • 使用闭包可以一直存储父级的参数或者变量

  • 不被外界的函数或者变量所干扰(污染)

        function fn() {
            let a = 2;
            let obj = {
                a: function fn2() {
                    console.log(a);
                },
                b: function fn3() {
                    console.log(a);
                }
            };
            return obj;
        };
        let f = fn();
        console.dir(f);

二、闭包的弊端

  • 如果大量使用闭包,会导致栈内存太大,页面渲染变慢,性能受到影响,所以项目中需要合理使用闭包
  • 某些代码会导致栈溢出挥着内存泄漏,这些操作都是需要注意的
// 递归:函数执行中再次调用自己
// 下面案例是死递归,Uncaught RangeError: Maximum call stack size exceeded 即内存溢出
function fn(x){
    fn(x+1);
}
fn(1);
  • 如果在项目中用递归,一定要有一个结束的条件,否则会导致内存溢出

三、利用闭包机制实现高阶编程技巧

  • 模块化思想
  • 惰性函数
  • 柯理化函数
    • react高阶组件
    • 函数的防抖和节流
    • bind
  • compose组合函数

3.1 模块化思想

单例 -> AMD(require.js) -> CMD(sea.js) -> CommonJS(NODE) -> ES6Moudle

  • 没有模块化思想之前,团队协作开发或者代码量较多的情况,会导致全局变量污染(全局变量冲突),为了避免这种情况,就需使用闭包形成私有作用域,避免影响全局上下文。
// 没有模块化之前,不同的板块会由不同的人员来开发,定义的变量可能会相同,这样很容易造成全局变量污染(下面的变量覆盖上面的变量)
// 实现天气板块
var time = "2021-10-13";
function getQuery() { "..." }
function changeCity() { "..." }

// 实现咨询板块
var time = "2021-10-13";
function getQuery() { "..." }
function changeCity() { "..." }
// 为了避免变量全局污染,会把不同板块的代码放在一个自执行函数中,形成闭包的保护作用,防止了全局变量污染
// 但是这样产生的问题是:每个版块的代码都是私有的,不能相互调用

(function () {
    // 实现天气板块
    var time = "2021-10-13";
    function getQuery() { "..." }
    function changeCity() { "..." }
})();

(function () {
    // 实现咨询板块
    var time = "2021-10-13";
    function getQuery() { "..." }
    function changeCity() { "..." }
})();
// 针对上面的问题,想要调用别人的板块的方法
// 但是这样会出现的问题是:在全局不能挂载太多方法,挂载多了,还是会引发全局变量的污染
(function () {
    // 实现天气板块
    var time = "2021-10-13";
    function getData() { "..." }
    function changeCity() { "..." }
    // 把需要供别人调用的API方法挂载到全局
    window.getData = getData;
})();

(function () {
    // 实现咨询板块
    var time = "2021-10-13";
    function changeCity() { "..." }
    getData();
})();
  • 针对上面的问题,想到利用对象
  • 对象的特点: 每一个对象都是一个单独的堆内存空间(单独的实例->Object),这样即使多个对象中的成员名字相同,也互不影响
  • 仿照其他后台语言,其实obj1/obj2不仅仅称为对象名,更被称为[命名空间](给堆内存空间起一个名字)
  • 每一个对象都是一个单独的实例,用来管理自己的私有信息,即使名字相同,也互不影响,其实这就是“JS中的单例设计模式”
 var obj1 = {
    a: 1,
    b: 2,
    query() { "..." }
}
var obj2 = {
    a: 1,
    b: 2,
    query() { "..." }
}

return一个对象,把需要供别人调用的方法暴露出来,并用一个变量来接收返回值,这样其他模块就可以调用了

// 高级单例设计模式 -> 闭包+单例的结合(早期JS模块化思想)
 var weatherMoudle = (function () {
    // 实现天气板块
    var time = "2021-10-13";
    function getData() { "..." }
    function changeCity() { "..." }
    return {
        getData
    }
})();

var infoMoudle = (function () {
    // 实现咨询板块
    var time = "2021-10-13";
    function changeCity() { "..." }
    weatherMoudle.getData()
})();

3.2 惰性函数

  • 举个例子:比如要获取页面中某个元素的样式,我们通常会用getComputedStyle()方法
  • window.getComputedStyle(元素):获取当前元素经过浏览器计算的样式(返回样式对象)
  • 但这个方法有兼容性问题:在IE6~8中,不兼容这种写法,需要使用“元素.currentStyle”来获取
  • “属性 in 对象”: 检测当前对象是否有这个属性,有则返回true,没有则返回false

于是我们可以写这样一个方法

function getCss(element, attr) {
    if ('getComputedStyle' in window) {
        return window.getComputedStyle(element)[attr]
    }
    return element.currentStyle[attr];
}
var body = document.body;
console.log(getCss(body, 'height'));
console.log(getCss(body, 'margin'));
console.log(getCss(body, 'background'));

但这样的方法并不好,每次调用getCss方法都要判断是否有这个属性,即使浏览器并没有改变,同样要判断

于是我们可以进行优化:第一次执行的getCss我们已经知晓是否兼容了,第二次及以后再次执行getCss,则不想在处理兼容的校验了,其实这种思想就是“” 惰性思想(懒,干一次搞定的,绝对不去干第二次)

// 把校验的过程 即'getComputedStyle' in window 存起来,这样每次校验的时候校验的是flag的值,少了查找的过程
var flag = 'getComputedStyle' in window
function getCss(element, attr) {
    if (flag) {
        return window.getComputedStyle(element)[attr]
    }
    return element.currentStyle[attr];
}
var body = document.body;
console.log(getCss(body, 'height'));
console.log(getCss(body, 'margin'));
console.log(getCss(body, 'background'));

但这样其实不算严谨意义上的惰性思想

function getCss(element, attr) {
    // 第一次执行,根据函数是否兼容,进行函数的重构
    if ('getComputedStyle' in window) {
        getCss = function getCss(element, attr) {
        return window.getComputedStyle(element)[attr];
    }
} else {
    getCss = function getCss(element, attr) {
        return element.currentStyle[attr];
    }
}
// 为了保证第一次也可以获取信息,则需要把重构的函数执行一次
return getCss(element, attr)
}
var body = document.body;
console.log(getCss(body, 'height'));
console.log(getCss(body, 'margin'));
console.log(getCss(body, 'background'));

getCss(body, 'height')函数第一次执行,正常校验,然后走第一个小函数,执行第一个小函数,拿到结果

第二次再执行getCss(body, 'margin'),不会再执行大函数内容了,会直接执行小函数,即不会走判断,第三次也一样

这就是标准的“惰性思想”

第一次执行getCss,形成一个私有上下文,在这个私有上下文下,把其中的某个堆(小函数getCss)又重新赋值给全局下的getCss,此时,第一次执行上下文不释放,形成闭包;之后再次执行getCss其实执行的就是第一次没被释放的闭包,保证第二次和第三次都执行的是小函数getCss。

3.3 柯理化函数

函数柯理化:预先处理的思想(形成一个不被释放的闭包,把一些信息存储起来,以后基于作用域链,访问到事先存储的信息,然后进行相关的处理,所有符合这种模式(或者闭包应用的)都被称为柯理化函数。

举例:写出一个方法,实现第一次执行sum(20) -> 输出10+20,第二次执行sum(20, 30) -> 输出10+20+30,以此类推

// x为预先存储的值
function curring(x) {
   // ?
}
var sum = curring(10);
console.log(sum(20)); // 10+20
console.log(sum(20, 30)); // 10+20+30

首先需要在curring函数中写一个返回值,用sum来接收

因为返回的函数参数可能是一个或多个,如何能获取到不确定个数的参数信息呢?

  1. 参数可以用 ...args :基于ES6的剩余运算符获取传递的实参信息 -> 数组
// x为预先存储的值
function curring(...args) {
   // ?
}
var sum = curring(10);
console.log(sum(20)); // 10+20
console.log(sum(20, 30)); // 10+20+30
  1. arguments:arguments是个内置的实参集合,它返回的是类数组;需要把类数组转化为数组
    1. var args = Array.from(arguments);
    2. var args = [].slice.call(arguments);
function curring(x) {
    return function (...args) {
        args.unshift(x); // unshift() 在数组开头添加一项
        // 数组求和方案一
        var total = 0;
        for (let i = 0; i < args.length; i++) {
            total += args[i]
        }
        return total
    }
}
var sum = curring(10)
console.log(sum(20));
console.log(sum(20, 30));
 function curring(x) {
    return function (...args) {
        args.unshift(x); // unshift() 在数组开头添加一项
        // 数组求和方案二
        var total = 0;
        args.forEach((item) => {
            total += item;
        })
        return total
    }
}
var sum = curring(10)
console.log(sum(20));
console.log(sum(20, 30));

以上两种数组求和的方法都是用了循环,不同的是上面for循环运用了命令式编程,下面forEach循环运用了函数式编程

  • 命令式编程:自己编写代码,管控运行的步骤和逻辑(自己灵活掌控执行步骤)
  • 函数式编程:具体实现的步骤已经被封装称为方法,我们只需要调用方法获取结果即可,无需关注怎么实现的(用起来方便,代码量减少),但弊端是自己无法灵活掌控执行步骤(项目中大部分用函数式编程)
// 数组求和方案三
// ary.join() 用指定的分隔符把数组各项进行分隔并转变为字符串
// eval() 把字符串变成JS表达式
var total = eval(args.join('+'));
// 数组求和方案四
function curring(x) {
    return function (...args) {
        args.unshift(x);
        // 数组求和
        return args.reduce((result, item) => result + item)
    }
}
var sum = curring(10)
console.log(sum(20)); // 10+20
console.log(sum(20, 30)); // 10+20+30

3.4 compose组合函数

  • 在函数式编程中有一个很重要的概念就是函数组合,实际上就是把处理数据的函数像管道一样连接起来,然后让数据穿过管道得到最终的结果。例如:
const add1 = (x) => x + 1;
const mul3 = (x) => x + 3;
const div2 = (x) => x / 2;
div2(mul(add1(0))); // 3

而这样的写法可读性太差了,我们可以构建一个compose函数,它接受多个函数作为参数(这些函数都只接受一个参数),然后compose返回的也是一个函数,达到以下的效果:

const add1 = (x) => x + 1;
const mul3 = (x) => x + 3;
const div2 = (x) => x / 2;
// div2(mul(add1(add1(0)))); // 3

const operate = compose(div2, mul3, add1)
operate(0) //=> 相当于div2(mul3(add1(0)))
operate(2) //=> 相当于div2(mul3(add1(2)))

简而言之,compose可以把类似于f(g(h(x))) 这种写法简化成 compose(f, g, h)(x) 那么compose函数的编写为

const add1 = (x) => x + 1;
const mul3 = (x) => x + 3;
const div2 = (x) => x / 2;

// funcs存储的是最后需要执行的函数及其顺序(最后传递的函数优先执行)
//    + 执行compose只是把最后要执行的函数及顺序事先存储起来,函数还没有执行(柯理化思想)
//    + 返回一个operate处理函数,执行operate,并且传递初始值,才按照之前存储的函数及顺序依次执行函数
function compose(...funcs) {
    // funcs -> [div2, mul3, add1]
    return function operate(x) {
        // x->0 初始值

        // 这里需判断一下compose如果没传值的情况 
        if (funcs.length === 0) return x

        // 此时我们需要先把add1执行,把初始值x传给add1,把add1执行的结果传给mul3,执行mul3,再把mul3执行的结果传给div2,最后执行div2 => 也就是依次遍历数组中的每一项,把上一次处理后的结果传给下一次,可以想到数组的reduce方法

        // 如果只传了一个函数,则没有必要用reduce方法
        if (funcs.length === 1) return typeof item !== "function" ? funcs[0](x) : x;

        return funcs.reduceRight((result, item) => {
            // 这里可以先做个判断,判断item是否为韩束,不是函数则跳过,执行下一个函数
            if (typeof item !== "function") return result
            return item(result)
        }, x)

    }
}
const operate = compose(div2, mul3, add1)
operate(0) //=> 相当于div2(mul3(add1(add1(0))))
operate(2) //=> 相当于div2(mul3(add1(add1(2))))

3.5 函数的防抖(debounce)和节流(throttle)

在高频触发场景下,需要进行防抖和节流

  • 狂点一个按钮
  • 页面滚动
  • 输入模糊匹配
  • 输入框金额后实时试算利息等

我么自己设定,多长时间内,触两次及以上就算“高频”:封装方法时候需要指定这个频率(可以设置默认值)

防抖

在某一次高频触发下,我们只识别一次(可以控制开始触发,还是最后一次触发)

详细:假设我么规定500ms触发多次算是高频,只要我们检测到是高频触发了,则在本次频繁操作下(哪怕你操作了10min),也只触发一次

// 点击按钮
submit.onClick = function () {
    console.log('ok')
}

节流

在某一高频触发下,我们不是只识别一次,按照我们设定的间隔时间(自己规定的频率),没到这个频率都会触发一次

详细:假设我们规定频率是500ms,我们操作了10min,触发的次数=(10601000)/500

// 页面滚动
window.onscroll = function () {
    // 默认情况下,页面滚动中:浏览器在最快的反应时间内(4-6ms),就会识别监听一次事件触发,把绑定的方法执行,这样导致方法执行的次数过多,造成不必要的资源浪费
    console.log('ok')
}
3.5.1 业务场景中处理防抖
// 业务场景中的处理技巧1:标识处理
let flag = false;
submit.onclick = function () {
    if (flag) return;
    flag = true;
    setTimeout(() => {
        // 事件处理完
        flag = false;
    }, 1000)
}

// 业务场景中的处理技巧2:按钮重置为灰色,移除事件绑定
let flag = false;
function handle() {
    submit.onclick = null;
    submit.disabled = true;
    // ...
    setTimeout(() => {
        submit.onclick = handle;
        submit.disabled = false;
    }, 1000)
}
submit.onclick = handle;

但以上处理都不算严谨意义上的防抖,我们可以封装一个debounce方法

<button id="submit">点击</button>
 /*
 * func:具体要处理的业务函数
 * wait: 规定多长时间算高频触发
 * immediate:是否是在最开始触发
**/
function debounce(func, wait, immediate) {
    // 多个参数及传递默认的处理
    if (typeof func !== "function") throw new TypeError("func must be an function!");
    if (typeof wait === "undefined") wait = 500;
    if (typeof wait === "boolean") {
        immediate = wait;
        wait = 500;
    }
    if (typeof immediate !== "boolean") immediate = false;

    console.log(wait);
    // 设定定时器返回值标识
    // 定时器目的:检测500ms内是否会触发第二次,如果有,则为高频触发;
    // 第一次proxy执行,设置一个定时器,等4ms后,第二次proxy执行,清除之前设定的定时器,重新设定,再去检测500ms内是否有第二次触发
    // 比如按钮疯狂点击了100次,之前99次定时器都清除了,只留最后一个,等过了500ms,发现没有触发第二次,则执行handle函数,所以这个handle函数最终只执行一次
    let timer = null;
    return function proxy(...params) {
        // 我们无法控制浏览器多长时间触发一次,所以把要处理的逻辑放到代理函数中,用代理函数proxy来控制handle

        let _this = this;
        let now = immediate && !timer;
        clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null;
            !immediate ? func.call(_this, ...params) : null;
        }, wait);

        // 第一次触发就立即执行
        now ? func.call(_this, ...params) : null;
    }
}

function handle(ev) {
    // 具体点击时处理的业务
    // 弹窗、跳转页面、调接口等
    console.log('ok', this, ev);
}
submit.onclick = debounce(handle, 500, true)
// submit.onclick = proxy; // 疯狂点击的情况下,proxy会被疯狂执行,我们需要在proxy中根据频率管控handle的执行次数
// submit.onclick = handle; // 如果这样写,浏览器每过4-6ms就要执行一次 handle -> this:submit 传递一个事件对象

柯理化思想:

执行debounce函数,只是把未来需要执行业务逻辑的函数func,和需要管控的频率wait,和控制在哪个阶段触发的标识immediate这三个参数预先存起来,然后把返回的函数赋值给onclick,此时debounce执行->形成闭包;

点击onclick,实际上就是执行proxy方法

3.5.2 业务场景中处理节流

节流.png

/*
* func:具体要处理的业务函数
* wait: 规定多长时间触发一次
**/
function throttle(func, wait) {
    // 多个参数及传递默认的处理
    if (typeof func !== "function") throw new TypeError("func must be an function!");
    if (typeof wait === "undefined") wait = 500;

    let timer = null;
    let privious = 0; //记录上一次触发(操作)的时间
    return function proxy(...params) {
        let _this = this;
        let now = new Date(); // 记录当前这次触发操作的时间
        let remaining = wait - (now - privious); // 拿两次时间间隔差和wait进行比较
        if (remaining <= 0) {

            // 临界点的处理
            clearTimeout(timer);
            timer = null;

            // 两次间隔时间超过wait了,直接执行即可
            privious = now;
            func.call(_this, ...params);
        } else if (!timer) {
            // 之前没有设置过定时器,再设置定时器
            // 两次触发的间隔时间没有超过wait,则设置定时器,让其等待remaining这么久之后执行一次
            timer = setTimeout(() => {
                clearTimeout(timer);
                timer = null;
                privious = new Date(); // 此时不能用now赋值,因为中间有时间消耗
                func.call(_this, ...params);
            }, remaining)
        }
    }
}

function handle() {
    console.log('ok');
}

window.onscroll = throttle(handle, 1000); // 相当于执行 window.onscroll = proxy