字节一面——当我从闭包谈到高阶函数,面试官:来道 curry 函数尝尝😊

8,753 阅读10分钟

简历缺少有技术深度的项目吗?最近在做开源,实现一个脚手架,涉及广泛的工程化知识
如果你感兴趣参与贡献,或者想加入社区聊聊技术、工作、八卦,可以添加我的联系方式:Tongxx_yj。
GitHub 链接:github.com/xun082/crea…

一、前言

前段时间参与字节面试,一面中问到我 闭包 ,我照着自己的理解从闭包的特性到高阶函数,再提到了闭包的应用,如 curry函数
或许是因为我巴拉巴拉讲了挺多的,面试官大大在准备第二道面试题的时候,让我实现一个 curry函数:

// 实现一个add函数
// return 1 + 2 + 3 + 2 传参为undefined时返回sum
add(1,2,3)(2)()
// 8

😶😶😶……本人只懂概念没有实操过啊😭👊
没办法,硬着头皮上,在面试官的循循善诱下终于ac了😭
这篇文章将会帮助大家了解闭包的概念、延伸概念、应用以及优劣,顺带解决这个题目。

二、闭包

2.1 闭包的概念

闭包是由一个函数以及与其相关的引用环境组合而成的实体。闭包可以在函数内部访问外部函数的变量,并且这些变量可以在外部函数执行结束后仍然保持其状态。
听起来可能有点抽象,咱们来段代码:

function outerFunction(x) {
  return function innerFunction(y) {
    return x + y;
  };
}

// 创建闭包函数
var closure = outerFunction(5);

// 调用闭包函数
var result = closure(3);

console.log(result); // 输出:8

可以看到 innerFunction 可以获取到传入 outerFunction 的参数,可见原本在外部函数执行结束后本该销毁的地址得到了保留。
在这里我们总结一下闭包的特点:

  • 闭包可以访问和修改其创建时捕获的外部环境中的变量值,从而实现状态的保存和共享。
  • 闭包可以私有化变量,从而避免命名冲突污染全局作用域
  • 闭包可以延长变量的生命周期,从而实现回调、事件处理等高级操作。

有的同学可能会联想到高阶函数,或许对高阶函数闭包存在不清晰的认知,那么我们就再来讲一下高阶函数

2.2 高阶函数

高阶函数指的是能够接受函数作为参数或返回函数作为结果的函数,如 Array.prototype.mapArray.prototype.filterArray.prototype.reduce 就是高阶函数的实现。 老样子,我们用代码说明一下:

2.2.1 接收函数

// 定义接收函数的高阶函数
function times(n, f) {
  for (var i = 0; i < n; i++) {
    f(i);
  }
}

// 使用接收函数的高阶函数
times(5, function(x) {
  console.log("Hello, " + x + "!");
});

// 输出:
// Hello, 0!
// Hello, 1!
// Hello, 2!
// Hello, 3!
// Hello, 4!

2.2.2 返回函数

// 定义返回函数的高阶函数
function add(x) {
  return function(y) {
    return x + y;
  };
}

// 使用返回函数的高阶函数
var addFive = add(5);
console.log(addFive(3)); // 输出:8
console.log(addFive(7)); // 输出:12

明显可以看出闭包就属于返回函数的一类,由此得知,闭包高阶函数的一种特殊形式。

三、常见应用

接下来我们再来聊一聊关于闭包的常见应用:

3.1 防抖

防抖的原理是在一段连续触发的时间内,只执行最后一次操作。

function debounce(func, delay) {
  let timer;

  return function () {
    const context = this;
    const args = arguments;

    // 再次调用时,清除time,重新计时
    clearTimeout(timer);
    timer = setTimeout(() => {
      // 通过apply执行传入的函数
      func.apply(context, args);
    }, delay);
  };
}

3.2 节流

节流的原理是在一段时间内,固定执行操作的频率。

function throttle(func, interval) {
  let timer;

  return function () {
    const context = this;
    const args = arguments;

    if (!timer) {
      timer = setTimeout(function () {
        func.apply(context, args);
        // 触发完成后清除timer,进入下一周期
        timer = null;
      }, interval);
    }
  };
}

3.3 curry函数

柯里化(Currying)是一种将多个参数的函数转换为接受单个参数的函数序列的技术。借此我们来完成前面提到的add函数(后面了解到这是一类很经典的面试题,纯属小子经验太少了🫠)

function add() {
  // 创建空数组来维护所有要 add 的值
  const args = []
  // curry 函数,存入每次调用传入的参数
  function curried(...nums) {
    if (nums.length === 0) {
      // 长度为0,说明调用结束,返回 args 的 sum
      return args.reduce((pre, cur) => pre + cur, 0);
    } else {
      // 长度不为0,将传入的参数存入 args,返回 curried函数给下一次调用
      args.push(...nums);
      return curried;
    }
  }

  // 一开始给 curried 传递 add 接收到的参数 arguments
  return curried(...Array.from(arguments));
}

console.log(add(1, 2)(1)()); // 输出:4
console.log(add(1)(2)(3)(4)()); // 输出:10
console.log(add(5)()); // 输出:5

3.4 迭代器

JavaScript 中,可以使用生成器函数(generator function)来实现迭代器。我们按照这个思路,来实现一个迭代器,满足遍历给定范围内的数字的功能

function rangeIterator(start, end) {
  // current 用于维护当前遍历到的值
  let current = start;
  
  return {
    next: function() {
      if (current <= end) {
        return { value: current++, done: false };
      } else {
        return { done: true };
      }
    }
  };
}

// 使用示例
const iter = rangeIterator(1, 5);

console.log(iter.next()); // 输出:{ value: 1, done: false }
console.log(iter.next()); // 输出:{ value: 2, done: false }
console.log(iter.next()); // 输出:{ value: 3, done: false }
console.log(iter.next()); // 输出:{ value: 4, done: false }
console.log(iter.next()); // 输出:{ value: 5, done: false }
console.log(iter.next()); // 输出:{ done: true }

3.5 链式调用

链式调用是一种编程风格,通过在对象上连续调用多个方法,使得代码看起来像是一条链条。每个方法都会返回对象本身,使得可以在同一个表达式中连续调用多个方法。如 Promiselodash库都有体现这样的风格。
这里给出一个链式调用函数,包含了 二次方、三次方、取反、随机数、取值等操作。

function Chainable(value) {
  // result 维护当前的运算值
  let result = value;

  this.square = function() {
    result = Math.pow(result, 2);
    return this;
  };

  this.cube = function() {
    result = Math.pow(result, 3);
    return this;
  };

  this.negate = function() {
    result = -result;
    return this;
  };

  this.random = function() {
    result = Math.random() * result;
    return this;
  };

  this.value = function() {
    return result;
  };
}

// 创建可链式调用对象
const chain = new Chainable(5);

const value = chain.square().cube().negate().random().value();
console.log(value); // 示例输出:-239.40167432539412

当然也可以换一种写法:

function chainable(val) {
  // result 维护当前的运算值
  let result = val;

  const square = function () {
    result = Math.pow(result, 2);
    return this;
  };

  const cube = function () {
    result = Math.pow(result, 3);
    return this;
  };

  const negate = function () {
    result = -result;
    return this;
  };

  const random = function () {
    result = Math.random() * result;
    return this;
  };

  const value = function () {
    return result;
  };

  return {
    square,
    cube,
    negate,
    random,
    value
  }
}

const value = chainable(5).square().cube().negate().random().value();
console.log(value); // 示例输出:-239.40167432539412

3.6 发布订阅模式

发布订阅模式是一种常见的设计模式,它用于在不同的对象之间建立松散耦合的联系。该模式包含两个核心概念:发布者(Publisher)订阅者(Subscriber)。发布者负责发布事件和通知订阅者,而订阅者则负责订阅事件并接收通知。
下面我通过闭包实现如上功能,因为结构比较复杂,所以注释写的非常详细,如果大家还看不懂的话,可以移步其他大佬的文章~

function eventEmitter() {
  // events 维护各事件以及对应的订阅者
  const events = {};

  // on 函数绑定订阅者(callback)至相应的事件(eventName)
  function on(eventName, callback) {
    // 如果 events 不存在该事件则创建该事件并赋值一个空数组存放订阅者
    events[eventName] = events[eventName] || [];
    // 存入订阅者
    events[eventName].push(callback);
  }

  // emit 函数发布事件(eventName),并传递相关参数(args)给订阅者
  function emit(eventName, ...args) {
    // 赋值订阅者数组
    const callbacks = events[eventName];
    if (callbacks) {
      callbacks.forEach(callback => callback(...args));
    }
  }

  // off 函数解除订阅关系
  function off(eventName, callback) {
    if (!callback) {
      // 订阅者为空,直接删除事件
      delete events[eventName];
    } else {
      // 订阅者不为空,筛选订阅者
      events[eventName] = events[eventName].filter(cb => cb !== callback);
    }
  }

  return { on, emit, off };
}

// 使用示例
const emitter = eventEmitter();

function handler1(name) {
  console.log(`${name} says hello from handler1`);
}

function handler2(name) {
  console.log(`${name} says hello from handler2`);
}

emitter.on('hello', handler1);
emitter.on('hello', handler2);

emitter.emit('hello', 'Alice'); // 输出 "Alice says hello from handler1" 和 "Alice says hello from handler2"

emitter.off('hello', handler1);
emitter.emit('hello', 'Bob'); // 只输出 "Bob says hello from handler2"

发布订阅模式还可以再完善一些,比如“一键清除事件”“订阅者只订阅一次”等功能,有兴趣可以自行实现。

3.7 缓存

闭包还可以实现缓存的效果,下面的例子我用 timeGap 来体现一下缓存的作用

function memoize(fn) {
  const cache = {}; // 缓存对象,用于存储函数执行结果

  return function (...args) {
    const startTime = performance.now()
    const key = JSON.stringify(args); // 将参数序列化为字符串作为缓存的键

    if (cache[key] === undefined) {
      // 如果缓存中没有该键对应的结果,则执行函数并将结果缓存起来
      cache[key] = fn.apply(this, args);
    }

    // 获取时间差 判断是否有缓存
    console.log('timeGap', performance.now() - startTime)
    return cache[key]; // 返回缓存的结果
  };
}

// 没啥意义的函数,纯属跑循环延长函数运行时间
const getTime = memoize((times) => {
  let a = 0
  for (let i = 0; i < times; i++) {
    a++
  }
  return times
})

getTime(10000000) // timeGap 4.4000000059604645
getTime(20000000) // timeGap 8
getTime(20000000) // timeGap 0

闭包的应用丰富多样,可以延申很多,如果有其他可以补充的也可以提醒一下我,才疏学浅难免会漏🫠。
那么接下来我们再回来看看闭包存在的一些问题。

四、闭包的缺点

先上结论:

  • 闭包对外部函数有引用时,若闭包被调用且未及时解绑,则会造成外部函数的变量无法被释放,导致内存泄露
  • 闭包涉及作用域链查找,性能相较直接访问局部、全局变量要低一些,在一些频繁调用或要求高性能的场景不适用
  • 闭包可以访问外部函数中的私有变量,这可能导致信息泄露和安全问题。如果闭包被滥用或不当使用,可能会导致数据被意外泄露给未授权的代码。

就这三个问题,我们依次还原案例并给出解决方案

4.1 内存泄露

内存泄露也是经常会被问到的,可以独立再写一篇介绍一下(挖个坑~)
下面我们仅针对于闭包造成的内存泄露给出案例:

function outer() {
  let count = 0;
  
  function inner() {
    count++;
    console.log(count);
  }
  
  return inner;
}

// 创建闭包
const fn = outer();

// 调用多次,但没有及时解绑闭包
fn();
fn();
fn();
fn();
// ...

// 结果:count 变量无法被释放,造成内存泄漏

解决方案:

  • 将闭包函数设置为 null:fn = null;
  • 将闭包函数重新赋值:fn = outer();

4.2 作用域链查找

作用域和作用域链也是常问的点(好家伙又埋坑) 这边的例子延用上例的 outer 函数

// 创建闭包 
const fn = outer();
// 调用多次,性能受到影响 
for (let i = 0; i < 10000; i++) {
  fn();
}

解决方案:

  • 考虑使用其他的编程模式或技术替代闭包
  • 将其应用范围限制在必要的情况下,并在可能的情况下将访问局部、全局变量的次数降到最低
  • 将闭包函数的执行结果缓存(这里可以用闭包实现缓存)起来,以便减少性能开销。

4.3 信息泄露与安全

体现的案例与4.1相同,如果 count 为隐私数据,则可能触发问题。
解决方案:

  • 将敏感信息存储在非闭包变量中,并仅向必要的代码公开(例如使用访问控制权限管理技术)
  • 避免其他代码访问到闭包中的变量,可以使用立即执行函数将闭包函数包装起来,并将其返回值设置为一个包含公开接口的对象,只有这些公开接口才能访问到闭包变量,可以有效地保护闭包中的私有信息。

五、总结

作为最最最经典的面试题之一,闭包的延伸内容确实很多,本文虽然以curry函数开头,但它只是本文中想要讲述的一个知识点,更多还是因此重新认识到闭包的深度。

⭐⭐⭐与此同时,分享一个深入学习知识的方法——延伸学习,从一个知识点出发,任何搭边的case都可以作为一个分支去丰富这个 map,到后面可以发现我们对某领域的认知会越来越贯通,也可以更好的面对各种开发需求、面试官延伸的各类问题。(鄙人写文的思路也是如此~ 有兴趣的话还可以看看俺其他文章哈,感谢大佬们支持😊)

ps:有内推想法的也可以找我呀