再谈js中的函数

前言

博主进入前端领域工作到现在也已经有两年的时间了,回看了两年多前刚开始学习js这门语言的时候写的关于函数的文章
,发现又有了新的理解,但在此博客中不对函数的创建和声明提升等概念做更多的理解,只说一些新的理解,概念性可能较强,也会有一些面试题以及专门针对react的优化方式。

js中的函数

在之前,我看过很多种对函数的称呼,除了函数本身外,还有方法、过程等,但是他们之间是有一定区别的。

  • 关于子程序
    子程序是指那些由一个或者多个语句组成的用来处理特定任务的程序,相对独立,听起来很像是函数吧,但是函数只是子程序的一种类型。
  • 子程序的分类
    子程序一般分为三种,函数是其中一种,剩下两种分别是过程方法,那么他们有个字有什么区别呢?
  • 函数、过程和方法
    • 函数: 最显著的特点就是具有返回值,那么可能会有人说了,我在js中写一个函数我不return不就没有返回值了吗,但实际上你不return,js会自动帮你return undefined,也就是说在js中函数必定有返回值
    • 过程: 过程实际上就是没有返回值的函数,又因为在js中不存在没有返回值的函数,因此js中不存在过程
    • 方法: 与函数、过程的区别就是,方法一般存在于类和对象中(可能也是因为这样,Java朋友总是和我杠说我把方法读成函数了)
      image.png

函数返回值的确定

前面说到在js中,函数永远都有返回值,那么函数的返回值又是由什么去确定的呢?

  • 返回值的确定
    • 函数的返回值是由调用时输入的参数定义时的环境确定的,调用时输入的参数影响结果大家都懂就不讨论了,只讨论函数定义时候的环境,比如下面这个面试题

    • 面试题目:

      image.png

      答案是x1,可能有人会说答案是'x2',因为f1调用时候内部没有变量a,应该取外部的a,那么外部的a最近的就是let a = '2'了。
      取外部的a确实没有错,但是因为第一点已经说了,函数的返回值也由定义时的环境决定,所以取值的时候取的是1,而不是2。

关于闭包

闭包相关的题目是出现频率巨高的面试题,也是老生常谈的问题了。在这里就说说闭包的定义和特点吧。

  • 闭包的定义

    • 如果在函数的里面可以访问到外面的变量,那么这个函数 + 这些变量 = 闭包
    • 闭包问题本质上是作用域问题
  • 闭包特点

    • 通过上面闭包的定义可以知道,闭包可以维持住一个变量,因此,可以使得外部对函数内部的变量进行访问,比如下面这个例子:


      image.png
  • 优缺点

    • 优点: 可以在外部对函数内部的变量进行访问
    • 缺点: 返回出来的变量,例如上面例子中的{ a: 1 },是被保留在内存中的,如果不及时清空的话会造成内存泄漏和性能问题。

关于this

this问题在js中也是老生常谈的问题的了,讨论的最多的就是它的指向问题,在这里分为三种情况进行讨论(严格模式下的this不考虑)。

  • 在非函数体中,this指向全局对象(浏览器为window对象,node中为global对象)。

  • 在非箭头函数中

    • 在非箭头函数中this的指向是最复杂的,面试的时候考的也多是费箭头函数中的this。在非箭头函数中,this和arguments一样是函数的一个内置参数,this指向一般为调用时候的外层环境,比如下面的例子:
      image.png

      又因为函数调用时候都会默认使用call的形式进行调用,所以上面的调用形式又可以改写成下面的代码,这样非常简单就能知道this究竟指向何物了:
      image.png
    • 但是上面的情况只是适合大部分情况下,有些情况还是不适合的,比如下面几种情况:
      • 在构造函数中,this会被强制绑定到构造出来的新对象中
      • 在使用addEventListener作为事件监听器的情况下,处理dom事件的函数中的this会指向该dom元素
      • setTimeout/setInterval中的this默认指向window
  • 在箭头函数中

    • 在箭头函数中是没有this的,它直接继承定义它时候所处的外部对象,并且call/apply/bind均不能改变改变其this的指向(猜测因为不是内置参数的原因),例如下面的例子:
      image.png
  • 一道巨坑的this面试题

let length = 10

function fn() {
    console.log(this.length)
}

let obj = {
    length: 5,
    method(fn) {
        fn()
        arguments[0]()
    }
}

obj.method(fn, 1) // 请问输出什么

可能有人会说答案是10和10,然而这时错的,正确的答案是window.length(当前页面的iframe数量)和2。
为什么呢?

  1. 首先我们考虑第一次执行fn时候,也就是method中fn()的时候,很明显fn中的this是指向window的,然而let length = 10因为使用的是let,所以并不会将length变量的值挂到window.length上去,所以fn()时候输出的是window.length的值
  2. arguments[0]()执行的时候,arguments是method的arguments,那么arguments[0]也就是执行fn,但是这么时候需要注意,arguments是一个对象,arguments[0]()相当于arguments[0].call(arguments),又因为obj.method(fn, 1)输入了两个参数,所以这里输出的是2

递归

说到函数就不能不说递归了,递归是指函数自身调用自身,它的使用范围很广,面试题出的也多,比如最常见的求阶乘斐波那契的第n位数:

  • 求阶乘n位数: j = n => n === 1 ? 1 : n * j(n - 1)

  • 求斐波那契n位数: f = n => n === 0 ? 0 : n === 1 ? 1 : f(n - 1) + f(n - 2)

  • 递归的优缺点
    从上面求阶乘和斐波那契n位数的解法可以看出: 递归可以使函数变得更加简洁,但同时也导致理解上更加困难,与此同时,更麻烦的是递归对于性能影响非常大,甚至导致爆栈,原因在于它会不断地将已经求得的结果再求一次,也就是说会进行大量地重复堆栈行为,例如在上述阶乘解法中,如果求的是第3位数,首先求得第一位数,在求第二位数的时候,又会再一次求第一位数,到了求第3位数的时候,又会重复求第一和第二位数,结果就导致重复的求值行为,如下图:


    image.png
  • 递归的优化
    在js中,所有的递归都可以改成循环的形式,例如上述的阶乘就可以该成为以下形式:

const j = n => {
    for(let i = n - 1; i >= 1; i--) {
        n = n * i
    }
    console.log(n)
}

j(3)  // 6

斐波那契则可以改写成如下:

const f = n => {
    let arr = [0, 1]
    for(let i = 0; i <= n - 2; i++) {
        arr[i + 2] = arr[i + 1] + arr[i]
    }
    console.log(arr[arr.length - 1])
}

除此之外,还可以将递归改写成尾递归的形式进行优化,但在此不再赘述。

调用栈

在上面讨论递归的时候有提到了调用栈这个东西,那么调用栈究竟是什么呢?

  • 首先什么是栈
    栈是一种线性的数据结构,其特点是先进后出,可以将其想象成一种容器,入栈是指将数据元素放入栈中,出栈是指从栈的顶部取出数据元素

  • 什么是调用栈以及js中的调用栈
    调用栈是指解释器追踪函数执行流的一种机制,通过这种机制,当执行环境中调用了多个函数时,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。
    在js中,由于其本身是单线程的,一次只能执行一件事情,所以js中的调用栈只有一个。结合前面栈的特点,可以对如下代码进行解释:


    image.png

    首先解释器将f1入栈并执行,其中f1执行的时候又执行了f2,然后解释器又把f2入栈并执行,执行完f2后,f2从调用栈中删去(出栈),然后f1出栈。

从对react的优化中看记忆化函数

该部分会涉及到react相关的知识,默认读者已具备相应的知识。首先将会介绍在react中的两种减少组件计算的优化方式react.memouseCallback,通过这两个优化再对记忆化函数进行理解。

  • react中的优化

    • react.memo

      • 问题描述1
        试看下面的场景:

        image.png

        该段代码中,App父组件包裹了Child子组件,App组件中有一个状态num, 当使用setNumnum的值进行改变的时候,App组件会重新执行,但是这个时候你会发现Child虽然没有用到num状态,但是也被重新执行了,这就造成了重复计算:
        image.png

      • 优化方案
        这个问题我们可以使用react.memo来解决,这个api和class组件下的pureComponent功能类似,只需要用react.memo对Child组件进行包裹即可:

        image.png

        然后你就会神奇地发现,当我点击按钮增加num的时候,那些重复执行的步骤消失了:
        image.png

    • useCallback

      • 问题描述2
        基于问题1,我们将代码改成如下:

        image.png

        这时候你点击value的按钮,会发现虽然Child组件虽然和value并没有什么关系,但是却导致了重新执行,之前react.memo已经解决的问题又出现了:
        image.png

        这是因为在更新App组件的时候,又重新声明了print函数,使得print函数的引用发生了改变,也就是说传入Child组件的函数发生了改变导致Child组件重新执行,但是实际上print与value并没有关系,这么问题在class组件里很好解决,但是在函数式组件里如何解决呢?

      • 优化方案
        这个时候是否可以创建一种,在App内部,只有当num的值发生变化时候才更新print函数的方案呢? 答案是使用useCallback,我们将print函数用useCallback进行包裹,变成如下:

        image.png

        这里传入的第二个参数就是当该参数中的值发生变化时候,才返回一个新的函数。
        优化结果,点击value按钮不再刷新Child组件了:
        image.png

        只有点击num按钮才会刷新:
        image.png

  • 记忆化函数
    从上面对react组件进行的优化方式来看,他们都使用了记忆化函数,也就是当前输入的参数如果之前已经求过结果,那么便不再重新执行,而是直接输出之前的结果。那么应该如何来实现这么一个函数呢?
    我们可以通过一道面试题得出一些结论:

const memo = (fn) => {
  请补全
}

const x2 = memo((x) => {
    console.log('执行了一次')
    return x * 2
  })
  // 第一次调用 x2(1)
console.log(x2(1)) // 打印出执行了,并且返回2
  // 第二次调用 x2(1)
console.log(x2(1)) // 不打印执行,并且返回上次的结果2
  // 第三次调用 x2(1)
console.log(x2(1)) // 不打印执行,并且返回上次的结果2

要实现记忆化函数实际上并不难,只需要找到一个容器对之前已经计算过的结果进行存储,当输入的参数和之前的参数一样时候,就直接从容器中取出结果,如果不是就执行函数,并对结果进行存储:

const memo = (fn) => {
    const memoed = key => {
        // 如果参数和之前不同则进行结果缓存
        if(!(key in memoed.cache)) {
            memoed.cache[key] = fn(key)
        }
        // 否则直接输出结果
        return memoed.cache[key]
    }
    memoed.cache = {}
    return memoed
}

执行结果:


image.png

函数柯里化(Currying)

函数柯里化也是现在前端面试的常客了,虽然我一直觉得嵌套太多层会导致代码难看

  • 什么是柯里化

柯里化是编译原理层面实现多参函数的一个技术

  • js中的函数柯里化和使用实例
    在js中,柯里化技术可以将一个接收多个参数的函数改成每次只接收一个参数, 这也是比较常见的,他可以使函数延迟执行,最常见的就是bind函数,它可以将一个函数绑定this后,再返回这个函数,使得这个函数的this固定化(对箭头函数无效),来看看一个简易bind函数的实现吧:
function bind(context, ...args) {
    return (...rest) => this.call(context, ...args, ...rest);
}

另外,也可以利用柯里化函数延迟执行的特性对代码进行优化,例子可以直接点这里查看

  • js柯里化面试题


    image.png

    该题中,最重要的是有两点:

    1. 找到一个容器对每次接收的参数进行存储
    2. 每次接收新的参数都需要对比接收到的参数的与传入的fn的形参是否相同

答案:


image.png

推荐阅读更多精彩内容