闭包的使用场景,使用闭包需要注意什么

586 阅读7分钟

闭包

什么是闭包

闭包很简单,就是能够访问另一个函数作用域变量的函数,更简单的说,闭包就是函数,只不过是声明在其它函数内部而已。

例如:

function getOuter(){
  var count = 0
  function getCount(num){
    count += num
    console.log(count) //访问外部的date
  }
  return getCount //外部函数返回
}
var myfunc = getOuter()
myfunc(1) // 1
myfunc(2) // 3

myfunc 就是闭包, myfunc 是执行 getOuter 时创建的 getCount 函数实例的引用。 getCount 函数实例维护了一个对它的词法环境的引用,所以闭包就是函数+词法环境

myfunc 函数被调用时,变量 count 依然是可用的,也可以更新的

function add(x){
    return function(y){
        return x + y
    };
}

var addFun1 = add(4)
var addFun2 = add(9)

console.log(addFun1(2)) //6
console.log(addFun2(2))  //11

add 接受一个参数 x ,返回一个函数,它的参数是 y ,返回 x+y

add 是一个函数工厂,传入一个参数,就可以创建一个参数和其他参数求值的函数。

addFun1addFun2 都是闭包。他们使用相同的函数定义,但词法环境不同, addFun1x4 ,后者是 5

即:

  • 闭包可以访问当前函数以外的变量
  • 即使外部函数已经返回,闭包仍能访问外部函数定义的变量与参数
  • 闭包可以更新外部变量的值

所以,闭包可以:

  • 避免全局变量的污染
  • 能够读取函数内部的变量
  • 可以在内存中维护一个变量

使用闭包应该注意什么

  • 代码难以维护: 闭包内部是可以访问上级作用域,改变上级作用域的私有变量,我们使用的使用一定要小心,不要随便改变上级作用域私有变量的值

  • 使用闭包的注意点: 由于闭包会使得函数中的变量都保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄漏。解决方法是,在退出函数之前,将不使用的局部变量全部删除(引用设置为 null ,这样就解除了对这个变量的引用,其引用计数也会减少,从而确保其内存可以在适当的时机回收)

  • 内存泄漏: 程序的运行需要内存。对于持续运行的服务进程,必须及时释放不再用到的内存,否则占用越来越高,轻则影响系统性能,重则导致进程崩溃。不再用到的内存,没有及时释放,就叫做内存泄漏

  • this指向: 闭包的this指向的是window

应用场景

闭包通常用来创建内部变量,使得这些变量不能被外部随意修改,同时又可以通过指定的函数接口来操作。例如 setTimeout 传参、回调、IIFE、函数防抖、节流、柯里化、模块化等等

setTimeout 传参

//原生的setTimeout传递的第一个函数不能带参数
setTimeout(function(param){
    alert(param)
},1000)


//通过闭包可以实现传参效果
function myfunc(param){
    return function(){
        alert(param)
    }
}
var f1 = myfunc(1);
setTimeout(f1,1000);

回调

大部分我们所写的 JavaScript 代码都是基于事件的 — 定义某种行为,然后将其添加到用户触发的事件之上(比如点击或者按键)。我们的代码通常作为回调:为响应事件而执行的函数。

例如,我们想在页面上添加一些可以调整字号的按钮。可以采用css,也可以使用:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>test</title>
    <link rel="stylesheet" href="">
</head>
<style>
    body{
        font-size: 12px;
    }
    h1{
        font-size: 1.5rem;
    }
    h2{
        font-size: 1.2rem;
    }
</style>
<body>
  
    <p>测试</p>

    <a href="#" id="size-12">12</a>
    <a href="#" id="size-14">14</a>
    <a href="#" id="size-16">16</a>

<script>
    function changeSize(size){
        return function(){
            document.body.style.fontSize = size + 'px';
        };
    }

    var size12 = changeSize(12);
    var size14 = changeSize(14);
    var size16 = changeSize(16);

    document.getElementById('size-12').onclick = size12;
    document.getElementById('size-14').onclick = size14;
    document.getElementById('size-16').onclick = size16;
</script>
</body>
</html>

IIFE

  var arr = [];
    for (var i=0;i<3;i++){
      //使用IIFE
      (function (i) {
        arr[i] = function () {
          return i;
        };
      })(i);
    }
    console.log(arr[0]()) // 0
    console.log(arr[1]()) // 1
    console.log(arr[2]()) // 2

函数防抖、节流

debouncethrottle 是开发中常用的高阶函数,作用都是为了防止函数被高频调用,换句话说就是,用来控制某个函数在一定时间内执行多少次。

使用场景

比如绑定响应鼠标移动、窗口大小调整、滚屏等事件时,绑定的函数触发的频率会很频繁。若稍处理函数微复杂,需要较多的运算执行时间和资源,往往会出现延迟,甚至导致假死或者卡顿感。为了优化性能,这时就很有必要使用 debouncethrottle了。

debounce throttle 区别

防抖 (debounce) :多次触发,只在最后一次触发时,执行目标函数。

节流(throttle):限制目标函数调用的频率,比如:1s内不能调用2次。

源码实现

debounce

// 这个是用来获取当前时间戳的
function now() {
  return +new Date()
}
/**
 * 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
 *
 * @param  {function} func        回调函数
 * @param  {number}   wait        表示时间窗口的间隔
 * @param  {boolean}  immediate   设置为ture时,是否立即调用函数
 * @return {function}             返回客户调用函数
 */
function debounce (func, wait = 50, immediate = true) {
  let timer, context, args

  // 延迟执行函数
  const later = () => setTimeout(() => {
    // 延迟函数执行完毕,清空缓存的定时器序号
    timer = null
    // 延迟执行的情况下,函数会在延迟函数中执行
    // 使用到之前缓存的参数和上下文
    if (!immediate) {
      func.apply(context, args)
      context = args = null
    }
  }, wait)

  // 这里返回的函数是每次实际调用的函数
  return function(...params) {
    // 如果没有创建延迟执行函数(later),就创建一个
    if (!timer) {
      timer = later()
      // 如果是立即执行,调用函数
      // 否则缓存参数和调用上下文
      if (immediate) {
        func.apply(this, params)
      } else {
        context = this
        args = params
      }
    // 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
    // 这样做延迟函数会重新计时
    } else {
      clearTimeout(timer)
      timer = later()
    }
  }
}

throttle

/**
 * underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait
 *
 * @param  {function}   func      回调函数
 * @param  {number}     wait      表示时间窗口的间隔
 * @param  {object}     options   如果想忽略开始函数的的调用,传入{leading: false}。
 *                                如果想忽略结尾函数的调用,传入{trailing: false}
 *                                两者不能共存,否则函数不能执行
 * @return {function}             返回客户调用函数
 */
_.throttle = function(func, wait, options) {
    var context, args, result;
    var timeout = null;
    // 之前的时间戳
    var previous = 0;
    // 如果 options 没传则设为空对象
    if (!options) options = {};
    // 定时器回调函数
    var later = function() {
      // 如果设置了 leading,就将 previous 设为 0
      // 用于下面函数的第一个 if 判断
      previous = options.leading === false ? 0 : _.now();
      // 置空一是为了防止内存泄漏,二是为了下面的定时器判断
      timeout = null;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    };
    return function() {
      // 获得当前时间戳
      var now = _.now();
      // 首次进入前者肯定为 true
      // 如果需要第一次不执行函数
      // 就将上次时间戳设为当前的
      // 这样在接下来计算 remaining 的值时会大于0
      if (!previous && options.leading === false) previous = now;
      // 计算剩余时间
      var remaining = wait - (now - previous);
      context = this;
      args = arguments;
      // 如果当前调用已经大于上次调用时间 + wait
      // 或者用户手动调了时间
      // 如果设置了 trailing,只会进入这个条件
      // 如果没有设置 leading,那么第一次会进入这个条件
      // 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了
      // 其实还是会进入的,因为定时器的延时
      // 并不是准确的时间,很可能你设置了2秒
      // 但是他需要2.2秒才触发,这时候就会进入这个条件
      if (remaining <= 0 || remaining > wait) {
        // 如果存在定时器就清理掉否则会调用二次回调
        if (timeout) {
          clearTimeout(timeout);
          timeout = null;
        }
        previous = now;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
      } else if (!timeout && options.trailing !== false) {
        // 判断是否设置了定时器和 trailing
        // 没有的话就开启一个定时器
        // 并且不能不能同时设置 leading 和 trailing
        timeout = setTimeout(later, remaining);
      }
      return result;
    };
  };

柯里化

在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,尽管它是 Moses Schnfinkel 和 Gottlob Frege 发明的。

var add = function(x) {
  return function(y) {
    return x + y;
  };
};
var increment = add(1);
var addTen = add(10);
increment(2);
// 3
addTen(2);
// 12
add(1)(2);
// 3

这里定义了一个 add 函数,它接受一个参数并返回一个新的函数。调用 add 之后,返回的函数就通过闭包的方式记住了 add 的第一个参数。所以说 bind 本身也是闭包的一种使用场景。

柯里化是将 f(a,b,c) 可以被以 f(a)(b)(c) 的形式被调用的转化。JavaScript 实现版本通常保留函数被正常调用和在参数数量不够的情况下返回偏函数这两个特性。

模块化

模块化的目的在于将一个程序按照其功能做拆分,分成相互独立的模块,以便于每个模块只包含与其功能相关的内容,模块之间通过接口调用

模块化开发和闭包息息相关,通过模块模式需要具备两个必要条件可以看出:

  • 外部必须是一个函数,且函数必须至少被调用一次(每次调用产生的闭包作为新的模块实例)
  • 外部函数内部至少有一个内部函数, 内部函数用于修改和访问各种内部私有成员
function myModule (){
    const moduleName = '我的自定义模块'
    var name = 'sisterAn'

    // 在模块内定义方法(API)
    function getName(){
        console.log(name)
    }
    function modifyName(newName){
        name = newName
    }

    // 模块暴露:  向外暴露API
    return {
        getName,
        modifyName
    }
}

// 测试
const md = myModule()
md.getName()    // 'sisterAn'
md.modifyName('PZ')
md.getName()    // 'PZ'

// 模块实例之间互不影响
const md2 = myModule()
md2.sayHello = function () {
    console.log('hello')
}
console.log(md) // {getName: ƒ, modifyName: ƒ}

常见错误

在循环中创建闭包

var data = []

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i)
  }
}

data[0]()   // 3
data[1]()   // 3
data[2]()   // 3

这里的 i 是全局下的 i,共用一个作用域,当函数被执行的时候这时的 i=3,导致输出的结构都是3

方案一:闭包

var data = []

function myfunc(num) {
  return function(){
    console.log(num)
  }
}

for (var i = 0; i < 3; i++) {
  data[i] = myfunc(i)
}

data[0]()   // 0
data[1]()   // 1
data[2]()   // 2

方案二:let

如果不想使用过多的闭包,你可以用 ES6 引入的 let 关键词:

var data = []

for (let i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i)
  }
}

data[0]()   // 0
data[1]()   // 1
data[2]()   // 2

方案三:forEach

如果是数组的遍历操作(如下例中的 arr ),还有一个可选方案是使用 forEach()来遍历:

var data = []

var arr = [0, 1, 2]
arr.forEach(function (i) {
  data[i] = function () {
    console.log(i)
  }
})

data[0]()   // 0
data[1]()   // 1
data[2]()   // 2

每天三分钟,进阶一个前端小 tip 面试题库 算法题库