函数

180 阅读13分钟

函数实际上是对象,每个函数都是Function类型的实例

定义函数的方法有

  • 函数表达式

    let sum = function(num1, num2) {
        return num1 + num2;
    }
    
  • 函数声明

    function sum(num1, num2) {
        return num1 + num2;
    }
    
  • 箭头函数

    let sum = (num1, num2) => {
        return num1 + num2;
    }
    
  • Function构造函数 (不推荐)

    let sum = new Function('num1', 'num2', 'return num1 + num2')
    

函数名

因为函数是对象,所以函数名就是指向函数对象的指针,这意味着一个函数可以有多个名称,而且不一定与函数本身紧密绑定。

使用不带括号的函数名会访问函数指针,而不会执行函数

如果函数是一个获取函数、设置函数,或者使用bind()实例化,那么标识符前面会加上一个前缀

function foo() {}
console.log(foo.bind(null).name); // bound foo

let dog = {
    years: 1,
    get age() {
        return this.years;
    },
    set age(newAge) {
        this.years = newAge;
    }
}
let d = Object.getOwnPropertyDescriptor(dog, 'age');
console.log(d.get.name); // get age
console.log(d.set.name); // set age

参数

ECMAScript函数既不关心传入的参数个数,也不关心这些参数的数据类型。

ECMAScript函数的参数只是为了方便才写出来的,也不存在验证命名参数的机制。

之所以会这样,主要是因为ECMAScript函数的参数在内部表现为一个数组。在使用function关键字定义(非箭头)函数时,可以在函数内部访问arguments(类数组)对象,从中取得传进来的每个参数值

arguments的值始终会与对应的命名参数同步,但这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已

另外,arguments对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数

对于命名参数而言,如果调用函数时没有传入这个参数,那么它的值是undefined。这类似于定义了变量而没有初始化

严格模式下,修改arguments的值不会再影响命名参数的值,其次,在函数中尝试重写arguments对象会导致语法错误。(代码也不会执行)

如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用arguments关键字访问,而只能通过定义的命名参数访问

ECMAScript中的所有参数都是按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用

函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用

ECMAScript函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载

把函数名当成指针有助于理解为什么ECMAScript没有函数重载

如果在ECMAScript中定义了两个同名函数,则后定义的会覆盖先定义的。

默认参数作用域与暂时性死区

因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的

给多个参数定义默认值实际上跟使用let关键字顺序声明变量一样

因为参数是按顺序初始化的,所以后定义默认值的参数可以引用先定义的参数

参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。否则会抛出错误

// 错误示范
function k(name = nume, nume = 'a') {...}

参数也存在于自己的作用域中,它们不能引用函数体的作用域

// 调用时不传第二个参数会报错
function k(name = 'echo', nume = defaultNume) {
    let defaultNume = 'a';
    retrn `k ${name} ${nume}`
}

函数内部

在ES5中,函数内部存在两个特殊的对象:argumentsthis

ES6又新增了new.target属性

arguments

它是一个类数组对象,包含调用函数时传入的所有参数。

这个对象只有以function关键字定义函数(相对于使用箭头语法创建函数)时才会有

虽然主要用于包含函数参数,但arguments对象其实还有一个callee属性,是一个指向arguments对象所在函数的指针

使用arguments.callee可以让函数逻辑与函数名解耦

function factorial(num) {
    if(num <= 1) {
        return 1;
    } else {
        // 使用arguments.callee代替了函数名,这意味着无论函数叫什么名称,都可以引用正确的函数
        return num * arguments.callee(num - 1);
    }
}

this

this在标准函数和箭头函数中有不同的行为

在标准函数中,this引用的是把函数当成方法调用的上下文对象,这时候通常称其为this值(在网页的全局上下文中调用函数时,this指向windows

window.color = 'red';
let o = {
    color: 'blue'
};
function sayColor() {
    console.log(this.color);
}
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'blue'

在箭头函数中,this引用的是定义箭头函数的上下文。

window.color = 'red';
let o = {
    color: 'blue'
}
let sayColor = () => color.log(this.color);
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'red'

在对sayColor()的两次调用中,this引用的都是window对象,因为这个箭头函数是在window上下文中定义的

在事件回调或定时回调中调用某个函数时,this值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的this会保留定义该函数时的上下文

function king() {
    this.royaltyName = 'echo';
    // this引用king的实例
    setTimeout(() => console.log(this.royaltyName), 1000);
}

this对象

在闭包中使用this会让代码变复杂。

如果内部函数没有使用箭头函数定义,则this对象会在运行时绑定到执行函数的上下文。

如果在全局函数中调用,则this在非严格模式下等于window,在严格模式下等于undefined

如果作为某个对象的方法调用,则this等于这个对象

匿名函数在这种情况下不会绑定到某个对象,这就意味着this在非严格模式下会指向window

每个函数在被调用时都会自动创建两个特殊变量:thisarguments。内部函数永远不可能直接访问外部函数的这两个变量。但是,如果把this保存到闭包可以访问的另一个变量中,则是行得通的

window.identity = 'The window';
let obj = {
    identity: 'obj',
    getIdentityFn() {
        let that = this;
        return function() {
            return that.identity;
        }
    }
}
console.log(obj.getIdentityFn()()); // 'obj'

caller

ES5也会给函数对象上添加一个属性:caller。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null

function outer() {
    inner();
}
function inner() {
    console.log(inner.caller);
}
outer();

以上代码ourter()调用了inner()inner.caller指向outer()

如果要降低耦合度,则可以通过arguments.callee.caller来引用同样的值:

...
console.log(arguments.callee.caller);
...

在严格模式下访问arguments.callee会报错。

ES5也定义了arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是undefined

这是为了分清arguments.caller和函数的caller而故意为之的。

严格模式下还有一个限制,就是不能给函数的caller属性赋值,否则会导致错误

new.target

ES6新增了检测函数是否使用new关键字调用的new.target属性。如果函数是正常调用的,则new.target的值是undefined;如果是使用new关键字调用的,则new.target将引用被调用的构造函数

function King() {
    if(!new.target) {
        throw 'King must be instantiated using "new"'
    }
    console.log('king instantiated using "new"');
}
new King(); // king instantiated using "new"
King(); // Error: King must be instantiated using "new"

函数属性与方法

在JS中,函数也是对象,因此有属性和方法。每个函数都有两个属性

  • length 保存函数定义的命名参数的个数

  • prototype 保存引用类型所有实例方法的地方

    这意味着toString()valueOf()等方法实际上都保存在prototype上,进而由所有实例共享。

    在ES5中,prototype属性是不可枚举的,因此使用for-in循环不会返回这个属性

函数还有两个方法:

  • apply(this, [params1, params2, ...])

  • call(this, params1, params2, ...)

在严格模式下,调用函数时如果没有指定上下文对象,则this值不会指向window。除非使用apply()call()把函数指定给一个对象,否则this的值会变成undefined

apply()call()真正强大的地方并不是给函数传参,而是控制函数调用上下文,即函数体内this值的能力

ES5出于同样的目的定义了一个新方法:bind()

bind()方法会创建一个新的函数实例,其this值会被绑定到传给bind()的对象:fn.bind(obj)

私有变量

严格来讲,JS没有私有成员的概念,所有对象都公有的。不过,倒是有私有变量的概念

任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量

私有变量包括函数参数、局部变量,以及函数内部定义的其他函数

如果函数中创建了一个闭包,则这个闭包能通过其作用域链访问其外部的私有变量。基于这一点,就可以创建出能够访问私有变量的公有方法

特权方法是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法:

  • 在构造函数中实现

    function MyObj() {
        // 私有变量和私有函数
        let p = 10;
        function pFun() {
            return false;
        }
        // 特权方法
        this.pubM = function() {
            p++;
            return pFun();
        }
    }
    

    这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法

    这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力

  • 使用静态私有变量实现特权方法

    通过使用私有作用域定义私有变量和函数来实现

    (function() {
        // 私有变量和私有函数
        let p = 10;
        function pFun() {
            return false;
        }
        // 构造函数
        M1yObj = function() {};
        // 公有和特权方法
        MyObj.prototype.pubM = function() {
            p++;
            return pFun();
        }
    })();
    

    在这个模式中,匿名函数表达式创建了一个包含构造函数及其方法的私有作用域。

    在这里声明MyObj并没有使用任何关键字,所以它变成了全局变量,可以在这个私有作用域外部被访问。

    注意在严格模式下给未声明的变量赋值会导致错误

    这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。

    使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。

箭头函数

箭头函数简洁的语法非常适合嵌入函数的场景

[1, 2, 3].map(i => i + 1);

箭头函数使用{}说明包含“函数体”,可以跟常规的函数一样包含多条语句。如果不使用{},那么箭头后面就只能有一行代码,且会隐式返回这行代码的值

箭头函数虽然语法简洁,但也有很多场合不适用

  • 箭头函数不能使用argumentssupernew.target

    箭头函数虽然不支持arguments对象,但支持收集参数的定义方式,因此也可以实现与使用arguments一样的逻辑

    let getSum = (...values) => {
        return values.reduce((x, y) => x + y, 0);
    }
    console.log(getSum(1, 2, 3)); // 6
    
  • 箭头函数不能用作构造函数

  • 箭头函数没有prototype属性

函数声明与函数表达式

事实上,JS引擎在加载数据时对它们是区别对待的。

JS引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义;而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。

除了函数什么时候真正有定义这个区别之外,这两种语法是等价的

函数声明

在执行代码时,JS引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部——函数声明提升

思考以下代码

console.log(sum(10, 10));
let sum = function(num1, num2) {
    return num1 + num2;
}

上面的代码会出错,因为这个函数定义包含在一个变量初始化语句中,这意味着代码如果没有执行到赋值的那一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。(这里就涉及到代码的预编译)

函数表达式

函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量。

如果function关键字后面没有标识符,则这样创建的函数叫做匿名函数

函数表达式跟js中的其他表达式一样,需要先赋值再使用。它不能像函数声明那样有函数声明提升

理解函数声明与函数表达式之间的区别,关键是理解提升

// 千万不要这样做
if(condition) {
    function doSomething() {...}
} else {
    function doSomething() {...}
}
// 这种写法在ES中不是有效的语法,JS引擎会尝试将其纠正为适当的声明。
// 多数浏览器会忽略condition直接返回第二个声明

如果把上面的函数声明换成函数表达式就没问题了

let doSomething;
if(condition) {
    doSomething = function () {...}
} else {
    doSomething = function () {...}
}

任何时候,只要函数被当做值来使用,它就是一个函数表达式

预编译

JS运行三步:

  • 语法分析

  • 预编译

    • 创建上下文对象

    • 找形参和变量声明,将其作为上下文对象的属性,并赋值undefined

    • 为形参赋值(值为实参)

    • 找函数声明,将其作为上下文对象,并赋值函数体

  • 解释执行

函数的使用

递归

递归函数通常的形式是一个函数通过名称调用自己

function fn(num) {
    return num <= 1 ? 1 : fn(num - 1);
}

尾调用优化

ES6规范新增了一项内存管理优化机制,让JS引擎在满足条件时可以重用栈帧。

“尾调用”即外部函数的返回值是一个内部函数的返回值

function outerFunction() {
    return innerFunction(); // 尾调用
}

所谓的优化就是当执行到outerFunction函数体,到达return语句时,即使把第一个栈帧(outerFunction)弹出栈外也没有问题,因为innerFunction的返回值也是outerFunction的返回值。这样无论调用多少次嵌套函数,都只有一个栈帧

尾调用优化的条件 就是确定外部栈帧真的没有必要存在了,涉及的条件如下:

  • 代码在严格模式下执行

  • 外部函数的返回值是对尾调用函数的调用

  • 尾调用函数返回后不需要执行额外的逻辑

  • 尾调用函数不是引用外部函数作用域中自由变量的闭包

闭包

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的

function foo() {
    var a=2;
    function baz() {
        console.log(a);
    }
    bar(baz);
}

function bar(fn) {
    fn(); // 这就是闭包
}

立即调用的函数表达式(IIFE)

它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。

(function() {...})()

模块模式

模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法

单例对象就是只有一个实例的对象

let singleton = function() {
    // 私有变量和私有函数
    let privateVariable = 10;
    function privateFunction() {
        return false;
    }

    // 特权/公有方法和属性
    return {
        publicProperty: true,
        publicMethod() {
            privateVariable++;
            return privateFunction();
        }
    }
}();

在Web开发中,经常需要使用单例对象管理应用程序级的信息。

在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。

以这种方式创建的每个单例对象都是Object的实例,因为最终单例都由一个对象字面量来表示。不过这无关紧要,因为单例对象通常是可以全局访问的,而不是作为参数传给函数的,所以可以避免使用instanceof操作符确定参数是不是对象类型的需求

模块增强模式

另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景

let application = function() {
    // 私有变量和私有函数
    let components = new Array();
    // 初始化
    components.push(new BaseComponent());
    //  创建局部变量保存实例
    let app = new BaseComponent();
    // 公共接口
    app.getComponentCount = function() {
        return components.length;
    }
    app.registerComponent = function(component) {
        if(typeof component == 'object') {
            components.push(component);
        }
    }
    // 返回实例
    return app;
}

这里创建了一个名为app的变量,其中保存了BaseComponent组件的实例。这是最终要变成application的那个对象的局部版本。在给这个局部变量app添加了能够访问私有变量的公共方法之后,匿名函数返回了这个对象,然后这个对象被赋值给application

理解作用域 & 词法作用域 & 作用域链

理解原型&原型链

继承

模块