红宝书之第十章:函数

701 阅读15分钟

函数的定义

函数有四种定义方式:

  • 1,函数声明
  • 2,函数表达式
  • 3,箭头函数
  • 4,使用Function创建对象

注意一点:第四种方式不推荐;但是对于理解函数是一个对象,函数名指向这个对象很有帮助;

箭头函数

ES6新增的,简化写法,非常是很嵌入函数的场景;

传参如果有一个参数可以省略小括号;

返回值如果是一行,可以省略return 语句;

值得注意的是:

不能使用arguments,super和new.target,也不能用于构造函数,还没有prototype属性;

函数名

函数名就是指向函数的一个指针,所以一个函数可以有多个函数名的指向;

ESMAScript6的所有函数都暴露一个只读name属性;就是一个函数的标识符,即使没有名称,也会返回一个空字符串,如果是Function构造函数创建的,则会标识成"anonymous"

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

如:bound foo; get age; set age;

理解参数:

问题:定义函数的时候需要接受两个参数,并不意味着调用的时候就传入两个参数,你可以传入一个或者是三个或者任意多个,解释器不会报错,这是为什么呢?

答案:因为ECMAScript函数的参数在内部表现为一个数组。函数调用时总接受一个数组,但是并不关心这个数组包含什么。如果数组什么也没有,没有问题;如果数组的元素超出了要求,也没问题。

arguments对象

在使用function关键字定义(非箭头)函数时,可以在函数内部访问arguments对象,从中取得传进来的每一个参数;

可以使用arguments.lenth属性检查传入的参数个数;根据不同的个数实现函数的重载;

参数同步

看看下面这个例子:

function doAdd(num1, num2) {
    arguments[1] = 10;
    console.log(arguments[0] + num2);
}

doAdd(20,30) // 30

这里打印的是30,问什么呢?

因为arguments对象的值会自动同步到对应的命名参数,所以修改了arguments[1]也会修改num2的值,因此两者都是10;但并不用意味着他们都访问同一个内存地址,他们在内存中是分开的,只不过会保持同步而已。

书上是这么说的:

这种同步是单向的,修改命名参数的值,不会影响arguments对象中相应的值;

但是经过我的验证,这个结论是片面的:

function doAdd2(num1 = 1, num2 = 2) {
    num2 = 10;
    console.log(arguments[0] + arguments[1]);
}
doAdd2(20, 30)  //50

如果给函数添加默认值,那么书上说的是对的;

function doAdd2(num1, num2) {
    num2 = 10;
    console.log(arguments[0] + arguments[1]);
}
doAdd2(20, 30)  //30

这里打印的依然是30,按照书上说的应该这里打印的是50;

function doAdd3(num1, num2) {
    arguments[1] = 10;
    console.log(num1 + num2);
}

doAdd3(20)  // NaN

上面代码打印的是NaN。

如果之传入了一个参数,然后把arguments[1]设置为某个值,那么并不会反应到第二个命名参数。这是因为argumenets对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的;

箭头函数的参数:

箭头函数中不能使用arguments关键字访问,而只能通过定义的命名参数访问;

虽然箭头函数没有arguments对象,但是可以在包装函数找那个把它提供给箭头函数;

function foo() {
    let bar = () => {
        console.log(arguments);
    }
    return bar()
}
foo(8)
函数没有重载

如何理解js的函数没哟重载?

ECMAScript函数没有签名,因为参数是有包含零个或者多个值的数组表示的。没有函数签名就没有重载; 后面定义同名的函数会覆盖前面定义的函数;但是可以通过判断arguments对象的长度或者参数类型做出相应的处理;

默认参数

在ES5及以前,实现默认参数就是判断这个是不是undefined,如果是就手动给一个默认值,如果不是就不做任何操作;

ES6之后就支持定义函数的时候给默认参数了;

有这样一个需求,有下面的一个函数,定义了两个默认参数,第一个默认参数是num1 = 10,第二个默认参数时候num2 = 20; 第一个参数与使用默认值,第二个参数传入特定值;该怎么做的?

尝试1:

function add (num1 = 10, num2 = 20) {
    return num1 + num2
}
console.log(add(num2 = 40))  // 60

这里不会理会你传入的num2=40,而是把这个表达式计算的到的值40赋值给了第一个参数,num2默认参数,所以打印60;

尝试2:

function add (num1 = 10, num2 = 20) {
    return num1 + num2
}
console.log(add(,40))  // SyntaxError: Unexpected token ','

语法错误;

其实正确的是传入add(undefined,40),该参数undefined相当于是没有传值,可以跳过第一个参数,给第二个参数传参;

使用默认参数时,arguments对象的值不反应参数的默认值,只反映传给函数的参数;

默认参数并不限于原始值或者对象类型,也可以是调用函数返回的值;

let romanNumerals = ['I','II' ,'III', 'IV' , 'V']

let ordinality = 0

function getNumerals() {
    return romanNumerals[ordinality++]
}

function makeKing(name = "Henry", numerals = getNumerals()) {
    return `King ${name} ${numerals}`
}

console.log(makeKing())   // I
console.log(makeKing('lsj','IV')) // IV
console.log(makeKing()) // II
console.log(makeKing()) // III
console.log(makeKing()) // IV

由上面代码所示:

  • 只有在函数调用的时候再回调用参数内部的函数求值,不会在函数定义时求值;
  • 计算默认值的函数只有在调用函数而且未传入相应参数清空下才会被调用;

箭头函数也可以用默认参数,必须使用()了;

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

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

例如

function makeKing(name = 'lsj', numerals = name) {
    ...
}

参数初始化顺序遵循“暂时性死区”规则,即前面定义的不能引用后面定义的;

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

参数扩展与收集

扩展参数

在给函数传参时,有时候不需要传入一个数组,而是要分开传入数组的元素;ES6之前需要借助apply()方法,ES6中通过扩展操作符很简单的实现找各种操作;

收集参数

在函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。

注意:

  • 收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组;
  • 因为收集参数的结果可变,所以只能把它作为最后一个参数;

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

总结:

  • 在函数内部使用...操作符,就是收集参数;
  • 在函数调用传参的时候使用...操作符,就是扩展参数;

函数声明与函数表达式

区别:

函数声明:js引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义;

函数表达式:必须等到代码执行到它的那一行,才会在执行上下文中生成函数定义;

值得注意一点是函数声明提升,在执行代码时,js引擎会先执行一遍扫描,把发现的函数声明提升到源代码书的顶部;所以就会出现在函数声明之前调用函数不会报错的原因;

函数作为值

因为函数名在ECMAScript中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另外一个函数。

看看下面的例子:

// 根据属性名字排序
function createComparisonFunction(prototypeName) {
    return function (object1, object2) {
        let val1 = object1[prototypeName]
        let val2 = object2[prototypeName]

        if (val1 < val2) {
            return -1
        } else if (val1 > val2) {
            return 1
        } else {
            return 0
        }
    }
}
var data = [
    {
        id: 1,
        name: 'lsj'
    }, {
        id: 2,
        name: 'zm'
    }, {
        id: 3,
        name: 'applyÍ'
    }
]
var data2 = data.sort(createComparisonFunction('name'))
console.log(data2)

很实用的一个排序方法;

默认情况下,sort()方法要对两个对象执行toString(),然后再来决定他们的顺序,但这样毫无意义。可以通过上面方法创建一个比较函数根据属性来做比较;

函数内部

函数内部存在两个特殊的想:arguments和this; ES6又新增了一个new.target属性;

arguments

主要用于包含函数参数,值得注意的是:arguments对象其实还有一个callee属性,是一个指向arguments对象所在函数的指针;

this

他在标准函数和箭头函数中有不同的意义;

  • 标准函数中,this引用的是把函数当成方法调用的上下文对象;
  • 箭头函数中,this引用的是定义箭头函数函数的上下文;
caller

这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null;

new.target

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

函数属性和方法

属性:

每一个函数都有两个属性:length和prototype

length属性保存函数定义的命名参数的个数;

prototype是保存引用类型所有实例方法的地方,向toString(),valueOf()等方法都保存在prototype上,进而由所有实例共享;

方法

函数还有下满几个方法call,apply,bind

都是改变this指向的;

函数表达式

为什么有了函数声明,还会有函数表达式的出现?

看看下面的场景,适用于函数表达式:

// 函数声明
if (falg) {
    function sayHi () {
        console.log('Hi')
    }
} else {
    function sayHi () {
        console.log('Yo')
    }
}

浏览器纠正这个问题的方式并不一样,多数浏览器会忽略falg直接返回第二个声明,Firefox会在flag为true时返回第一个声明。这种写法很危险,所以需要用下面的写法:

var sayHi;
if (falg) {
    sayHi = function () {
        console.log('Hi')
    }
} else {
    sayHi = function () {
        console.log('Yo')
    }
}

还有一个使用场景就是闭包,一个函数返回另外一个函数,然后用变量接受,也是一种函数表达式的用法;

递归

递归函数就是一个函数通过名称调用自己;

注意点:编写递归函数时,arguments.callee是引用当前函数的首选;

不过在严格模式下运行的代码不能访问argument.callee的,会访问出错,可以使用命名函数表达式达到目的;

尾调用优化

尾调用:外部函数的返回值是一个内部函数的返回值;

比较绕,看下代码:

function outFun() {
    return innerFun() // 尾调用
}
尾调用优化的条件
  • 代码在严格模式下执行;
  • 外部函数的返回值是对尾调用函数的调用;
  • 尾调用的函数返回之后不需要执行额外的逻辑;
  • 尾调用函数不是引用外部函数作用域中自由变量的闭包

这种尾调用优化在递归的场景下优化效果很明显;

闭包

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

this对象

在没有闭包的情况下;

1,如果内部函数没有箭头函数定义,this对象会在运行时绑定到执行函数的上下文。如果是全局正调用,在非严格模式this指向的window,在严格模式下this指向的是undefined;

2,如果作为某一个对象的方法调用,则thisz指向的值这个对象;

3,匿名函数在不会绑定到某个对象,就意味着this会指向window,除非在严格模式下指向的undefined;

闭包的情况:

每一个函数在被调用时都会自动创建两个特殊的变量,this和arguments。内部函数永远不可能直接访问外部函数的这两个变量。

不用箭头函数就是把this保存到闭包可以访问的另一个变量中,在内部函数调用这个变量是可以的

window.identity = 'The Window'
let obj = {
    identity:'My Object',
    getIdentity() {
        return this.identity
    }
}
console.log(obj.getIdentity());   // My Object
console.log((obj.getIdentity)());  // My Object
console.log((obj.getIdentity = obj.getIdentity)());  // The Window

这里关键是第二行和第三行;第二行调用(obj.getIdentity)()),虽然加了括号后看起来是对一个函数的引用,但是this值并没有改变。

第三行:执行了一次赋值,然后在调用赋值后的结果。因为赋值表达式的值是函数本身,this值不在和任何对象绑定,所以返回的是“The Window”

闭包引发的内存泄漏

看下满的代码:

function assignHandle() {
    let element = document.querySelector(".textDom")
    element.onclick = function() {
        console.log(element.className)
    }
}

以上代码创建了一个闭包,即element元素的事件处理程序,而这个事件处理程序有创建了一个循环引用;匿名函数引用assignHandle的活动对象,阻止了对element的引用计数清零;只要这匿名函数存在,element的引用计数就至少是1,就是说内存不会被回收;

对这个例子稍加修改可以避免这种情况:

function assignHandle() {
    let element = document.querySelector(".textDom")
    let className = element.className
    element.onclick = function () {
        console.log(className)
    }
    element = null
}

修改之后:闭包改为引用一个保存着element.id变量的id,从而消除了循环引用。不过,光有这一步还不足以解决内存问题;因为闭包还是会引用包含函数的活动对象,而其中包含element。即使闭包没有直接使用element,包含函数的活动对象上还是保存着这对它的引用。因此,必须把element设置为null,就解除了对这个COM对选哪个的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收;

立即调用的函数表达式

也成为自执行函数,就是定义一个匿名函数,定义完了之后加上小括号自己调用

在ES6之前经常用于模拟块级作用域;

私有变量

严格来讲,JavaScript 没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数

特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。

function Person(name) { 
    this.getName = function() { 
    return name; 
 }; 
 this.setName = function (value) { 
    name = value; 
 }; 
} 
let person = new Person('Nicholas'); 
console.log(person.getName()); // 'Nicholas' 
person.setName('Greg'); 
console.log(person.getName()); // 'Greg'

这段代码中的构造函数定义了两个特权方法:getName()和setName()。每个方法都可以构造函数外部调用,并通过它们来读写私有的name变量。在Person构造函数外部,没有别的办法访问 name。因为两个方法都定义在构造函数内部,所以它们都是能够通过作用域链访问 name 的闭包。私有变量name 对每个Person实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。

构造函数模式的缺点是每个实例都会重新创建一遍新方法。

静态私有变量
(function() {
  let name = "";
  Person = function(value) {
    name = value;
  };
  Person.prototype.getName = function() {
    return name;
  };
  Person.prototype.setName = function(value) {
    name = value;
  };
})();
let person1 = new Person("Nicholas");
console.log(person1.getName()); // 'Nicholas'
person1.setName("Matt");
console.log(person1.getName()); // 'Matt'
let person2 = new Person("Michael");
console.log(person1.getName()); // 'Michael'
console.log(person2.getName()); // 'Michael'

这里的Person构造函数可以访问私有变量name,跟getName()和setName()方法一样。使用这种模式,name变成了静态变量,可供所有实例使用。这意味着在任何实例上调用setName()修改这个变量都会影响其他实例。调用setName()或创建新的Person实例都要把name变量设置为一个新值。而所有实例都会返回相同的值

像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。

模块模式

单例对象(singleton)就是只有一个实例的对象。按照惯例,JavaScript 是通过对象字面量来创建单例对象的,如下面的例子所示:

let singleton = {
  name: value,
  method() {
    // 方法的代码
  },
};

模块模式是在单例对象基础上加以扩展,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式

// 私有变量和私有函数 
let components = new Array();
// 初始化
components.push(new BaseComponent());
// 公共接口
return {
  getComponentCount() {
    return components.length;
  },
  registerComponent(component) {
    if (typeof component == 'object') {
      components.push(component);
    }
  }
}; 
}();
模块增强模式

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

let singleton = (function() {
  // 私有变量和私有函数
  let privateVariable = 10;
  function privateFunction() {
    return false;
  }
  // 创建对象
  let object = new CustomType();
  // 添加特权/公有属性和方法
  object.publicProperty = true;
  object.publicMethod = function() {
    privateVariable++;
    return privateFunction();
  };
  // 返回对象
  return object;
})();