这是你了解的JS函数吗?

178 阅读8分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

本文同时参与 「掘力星计划」  ,赢取创作大礼包,挑战创作激励金

先来一个问题,以下代码会输出什么结果?(内心os: 好像没有问题我这篇文章没法儿继续了):

function func({a, b}, x, y) {
    console.log(arguments[0]);
}
func({a: 1, b:2, c:3}, x, y);

好吧,可能您已经知道这个答案了,不过我还得公布一下, 答案是:{a: 1, b:2, c:3}。读完本文你会了解函数的一些特性,以后少掉坑(其实是我自己掉坑里了~)。

好吧,以下正文开始。

正文

愿你读有所获

函数四种定义方式

  • 第一种: function funcName() {}这种声明方式相信大家都很熟悉,从一开始学习js函数开始,教程里就是这个写法。当然这种写法叫函数声明

  • 第二种:let funcName = function () {}这种写法也叫函数表达式,变量funcName是后面匿名函数的引用。看到这种写法我当时就想问了,为什么后面得是匿名函数,就不能加个名吗?答案是:可以加上函数名,执行效果一样,这里先简单介绍一下不同点后面将会详细说明:

    let func = function func1() {
        console.log('hello');
    }
    // 上面这种写法等同于:
    /*
    function func1() {
        console.log('hello');
    }
    let func = func1;
    */
    func()
    
  • 第三种:let func = () => {}这个是ES6新增语法,叫箭头函数。这种函数可以解决我们在引用this上下文的问题,还是举个例子🌰:

    let obj1 = {
        a: 1,
        func: function () {
            setTimeout(function () {
                console.log(this.a);//undefined
            }, 1000)
        }
    }
    obj1.func();
    let obj2 = {
        a: 1,
        func: function () {
            setTimeout(() => {
                console.log(this.a);// 1
            }, 1000)
        }
    }
    obj2.func();
    

    具体为什么是这个结果,请继续往下看,后面会详细讨论。(os:有点卖关子的感觉)

  • 第四种:let func = new Function("arg1", "arg2" ...., "console.log('hello')")最后一个为函数体,前面所有的是传递的参数。这个方法不推荐使用,因为会被解释两次,第一次解释的是常规的代码,第二次是解释传给构造函数的字符串,会影响性能。

上面介绍了四种函数声明方式,第四种不推荐使用,那么来对比一下前面三种。

函数共有的特性

不知道大家发现没,js的函数的参数的可写可不写,写出来只是为了方便我们使用,还有一点就是传的参数是无限的。先上代码

function func() {
    console.log('hello func');
}
func(1,2,3);
​
let func1 = function () {
    console.log('hello func1');
}
func1(1, 2, 3);
​
let func2 = () => {
    console.log('hello func2');
}
func2(1, 2, 3);

上面这种写法是完全不会报错的,所以这就有个问题了,函数没有签名,这就造成了函数没有重载的概念(ps: 在java语言中,可以通过不同参数列表重载方法)。这里还有重要的知识点:函数所有的参数传递都是值传递,如果传递参数的类型是对象类型,那么传递的值是这个对象的引用。这就意味着函数参数没有引用传递,这点开始让我很难理解,举个例子:

function func(x) {
    x.b = 2;
}
let obj = {a: 1};
func(obj);
console.log(obj);// {a: 1, b: 2}

看到上面这个结果,我当时想的是“这明明是引用传递啊”。从上面例子我们来分析一下,传递的值其实相当于c语言的一个指针,es还有一个特性就是基本类型的赋值是拷贝,比如以下代码

let a = 1, b;
b = a;
a = 2;

改变了a的值不影响b的值,obj是指向{a: 1}的指针,那么x其实是拷贝一份obj的值,函数体内x.b = 2相当于取了x指针指向的{a: 1}对象然后给这个对象新增了一个b = 2, 所以结果变成了{a: 1, b: 2}。这里如果不能理解,我建议先了解一下栈内存和堆内存。对象存放在堆内存,变量存放在栈内存。如果变量的值是对象类型,那么变量其实是一个指向对象的一个内存地址。

函数声明(function funcName() {}) vs 函数表达式(let funcName = function () {})

从字面上来看 函数声明函数表达式 一个是函数名直接跟在function声明之后,一个是把匿名函数赋值给了变量。至于有啥不同,先直接上代码

func1();
function func1 () {
    console.log('hello func1')
}
// func2();如果写在这里会直接报错
let func2 = function () {
    console.log('hello func2');
}
func2();

你猜这个代码能执行吗?那必须能啊。这就是函数声明与函数表达式的区别,函数声明会在任何代码执行之前先被读取并添加到执行上下文,这个过程叫函数声明提升。而函数表达式就是let变量的作用域,存在暂时性死区,所以不能在let声明之前调用函数。函数声明还有一个特点就是后面写的函数如果跟前面写的函数名一样,则后面的函数会覆盖前面的函数,举个例子:

func();// 打印 hello second
function func() {
    console.log('hello first');
}
function func() {
    console.log('hello second');
}

上面说了不同,下面我看来看看相同的地方(这里想了好久,总感觉缺了点什么,没什么说清楚)。

相同点一是他们都有arguments对象,上一小节里面写了函数的传递参数跟写的参数个数无关, 那么我们通过arguments对象获取到对应的参数,比如arguments[0]获取的是第一个参数,这里注意arguments对象不是Array,虽然能够通过arguments[index]的方式获取对应参数的值。arguments对象有个length属性可以获取到参数的个数,通过判断我们能做一件事情,大家可以思考一下能做什么?答案是:可以实现函数的伪重载,通过判断传递参数的个数来实现不同的逻辑。比如:

function add(x, y, z) {
    if (arguments.length === 2) {
        return x + y;
    }
    if (arguments.lenght === 3) {
        return x + y + z;
    }
}

还记得开头那个问题吗 ?为什么第一个参数解构了,通过arguments[0]获取的时候还是传进来的那个值呢?这是因为参数解构了,但是对象还是那个对象只是解构的方式方便我们获取值。

还有一个有趣的现象,在非严格模式下arguments和参数是同步的,但是两个访问的不是内存中的同一个值。更改了arguments对象中的值也会影响到命名参数,还在浏览器控制台执行以下代码,查看结果

function func(x) {
    console.log(x, arguments[0]);
    arguments[0] = 10;
    console.log(x, arguments[0]);
    x = 100;
    console.log(arguments[0])
}

在严格模式下,更改了arguments对象的值不会影响到命名参数的值。在浏览器控制台执行以下代码可以看到效果:

(function () {
  'use strict'
  function func(x) {
    arguments[0] = 10;
    console.log(x, arguments[0]);
  }
  func(1);
})();

arguments对象还有一个属性callee,这个属性可以方便我们在使用递归的时候解耦,还是先上代码:

// 使用函数名
function factorial(x) {
    if (x <= 1) {
        return x;
    }
    return x * factorial(x - 1);
}
// 使用callee
function factorialCallee(x) {
    if (x <= 1) {
        return x;
    }
    return x * arguments.callee(x - 1);
}
// 函数表达式类似

相同点二,这两种函数定义方式都能作为构造函数,使用new关键字创建对象。

箭头函数(()=>{})与其他两种对比

箭头函数是es6新增的函数,那么与其他两种函数相比有什么不一样呢?从上一小节我们了解了函数声明和函数表达式有arguments对象,箭头函数则没有。这就造成了一个问题,如果没有写命名参数,传入的参数将没办法使用,举个例子:

let func = () => {
    // 如果不写任何的命名参数,将没办法使用传入的参数
}
function func1() {
    // 这里我们不写命名参数仍然可以通过arguments对象来获取对应的参数,比如arguments[0]获取的就是第一个参数
}

第一个不同点:就是箭头函数没有arguments对象,虽然箭头函数没有arguments对象,但是有个代替方案能达到arguments一样的效果,这也是es6新增的扩展操作符(...), 通过扩展符也能取到所有的参数,代码如下:

let fun = (...values) => {
    console.log(values);
    // 第一个元素values[0], 第二个是values[1]依次类推
}

第二个不同点:在介绍箭头函数的时候介绍过,这个不同就this的指向。总结一句话就是:函数声明和函数表达式this是谁调用指向谁,箭头函数是谁创建指向谁。我们下面通过代码来验证这条结论:

var a = 1;
let obj = {a: 100};
function f1() {
    console.log(this.a);
}
f1(); // 输出 1
obj.f1 = f1;
obj.f1(); // 输出100
let f2 = () => {
   console.log(this.a);
}
f2(); // 输出 1 
obj.f2 = f2;
obj.f2(); // 输出 1,由于f2是由window传建的,所以放在对象内this还是指向的window

function Obj1 () {
    this.x = 2;
    setTimeout(() => {console.log(this.x)}, 1000);
}

function Obj2 () {
    this.x = 1;
    setTimeout(function() {console.log(this.x)}, 1000);
}

new Obj1();// 输出 2
new Obj2();// 输出 undefined

第三个不同点:箭头函数不能作为构造函数,不能通过new关键字创建对象。

总结

本文介绍了常用的四种函数定义方式和三种函数之间的对比。涉及的知识点如下:

  • 函数的定义和语法
  • 函数的参数
  • arguments对象
  • 不同函数定义中的this指向问题
  • 稍微带一句递归