重学JS(3):函数

536 阅读16分钟

前言

函数是JS中用得最广泛的一种语法,但在我们日常的使用过程中是否有真正的认识JS中函数?本篇将重新梳理学习有关JS函数的几个知识点:

  1. 不同的定义函数的方式
  2. 函数名与函数参数
  3. 函数内部属性
  4. 函数执行上下文
  5. 闭包
  6. 递归与尾调用优化

本章将最完整的去阐述JS中的函数,欢迎大家点赞收藏~

函数的定义方式

在JS中每个函数实际上都是对象,都是Function的实例,所以就跟其他引用类型一样,函数名只是一个指向该函数对象的指针,函数名不会与函数对象绑定,也就是意味着一个函数可以有多个名称,这些名称指向的是同一个函数对象。

函数声明式

function foo(params){
    // your code
}

函数表达式

let foo = function(params){
    // your code
}

构造函数式

let foo = new Function('params1', 'params2', ..., 'your code');

构造函数定义函数的方式中,最后一个参数会被解析为函数体,剩余前面的参数会被当作新函数的参数。在平时开发中,其实我是不推荐使用这种方式取定义一个函数的,因为这行代码会被执行两次,第一次会当作正常的JS代码执行去实例化一个对象,第二次是由于参数中含有函数体代码,解释器会去解释一遍这些代码,并且生成最终的函数。在追求极致性能的项目中使用这种方式定义函数必然会造成性能问题,其次,这种写法可读性很差。

箭头函数式(ES6新增)

// 只有一个参数时能省略括号
let foo = params => {
    // your code
}
// 0个或者1个以上参数时不能省略括号
let foo = (params1, params2) => {
    // your code
}
// 函数只有一行返回语句时可以省略花括号,该行语句会被JS引擎当作返回语句,即return xxx;
let foo = params => params

在ES6中新增了箭头函数,所以就有了箭头函数式定义函数的方法,在可以使用函数表达式的地方都可以使用箭头函数式来定义一个函数,但是这两种方式有着微妙但是很重要的区别(this指向),在第2小节将会说到

除此之外函数声明式和函数表达式也有着微妙但是很重要的区别(函数声明提升),在第3小节中将会说到。

函数名与函数参数

函数名

上面说到,函数本质是一个对象,跟其他引用类型一样,其名称只是指向该对象的一个指针,这意味着一个函数对象可以被多个指针指向,即同时有多个名称,看下面例子:

function foo(){
    console.log('This is foo function!');
}
foo() // This is foo function!
let fooAlias = foo
foo = null
fooAlias() // This is foo function!

在ES6中,函数对象还新增了一个name属性,来标识函数名称相关的信息,同时会根据不同类型的函数或者不同的定义方式添加对应的前缀,并且name属性是只读的,看下面例子:

function foo(){
    // ...
}
let bar = function(){
    // ...
}
let obj = {
    foo: 'I am foo',
    set foo(v){
        // ...
    },
    get foo(){
        // ...
    }
}
console.log(foo.name) // foo
console.log(bar.name) // bar
console.log((() => {}).name) // ''
console.log((new Function()).name) // anonymous
console.log(foo.bind(window).name) // bound foo
console.log(Object.getOwnPropertyDescriptor(obj,'foo').get.name) // get foo
console.log(Object.getOwnPropertyDescriptor(obj,'foo').set.name) // set foo

// name属性是只读的
foo.name = 'modify foo'
console.log(foo.name) // foo

时刻记着函数名是指向函数对象的一个指针这一点非常重要,有利于后续小节的深入理解。

函数参数

在函数参数这一块JS和JAVA有很大的区别,在JAVA中可以通过设置不同的参数个数和参数类型来对函数实现重载,这因为在JAVA中定义一个函数后会根据函数名和参数等生成对应的函数签名,在调用时会验证函数签名来调用对应的函数。而在JS中不存在验证函数签名的机制,这意味着JS引擎并不关心你传入参数的个数和类型,即使你在定义函数时指定了若干个形参,在调用时,传一个、二个或者不传实参,都不会报错。

这是因为在JS函数内部,函数参数的接收形式为数组,所以传参与否,函数都接收一个数组,只不过是这个数组的长度不同罢了。当然,JS引擎也将这个数组暴露了出来,在函数内部用arguments关键字就能访问到,arguments是一个类数组但非Array实例,所以可以用中括号加下标的形式访问到对应的参数,值得注意的是arguments的长度是根据传入参数的数量决定的而不是由定义函数时的参数个数决定的,看下面例子:

function foo(name, age){
    console.log(`${name} is ${age} years old!`)
}
function bar(){
    console.log(`${arguments[0]} is ${arguments[1]} years old!`)
}
foo('Tom', 18) // Tom is 18 years old!
foo('Tom') // Tom is undefined years old!
foo() // undefined is undefined years old!
bar('Tom', 18) // Tom is 18 years old!
bar('Tom') // Tom is undefined years old!
bar() // undefined is undefined years old!

在箭头函数中无法使用arguments属性,只能通过定义的参数名来访问:

let foo = () => `I am ${arguments[0]}`
foo('Tom') // Uncaught ReferenceError: arguments is not defined

实现重载

在JAVA中有函数重载的概念,上面也提到了JS函数调用时不存在验证函数签名的机制,并且函数名只是指向函数对象的一个指针,所以位置在后面的函数定义会覆盖前面的,所以在JS中是没有函数重载的:

function foo(name){
    console.log(`I am ${name}`);
}
function foo(name, age){
    console.log(`${name} is ${age} years old!`)
}
foo('Tom') // Tom is undefined years old!
foo('Tom', 18) // Tom is 18 years old!

但是可以利用arguments实现类似重载的特性:

// 实现根据不同参数个数调用不同方法
function foo(){
    if(arguments.length === 1){
        // ...
    }else if(arguments.length === 2){
        // ...
    }
    ...
}

函数内部属性

callee

callee是arguments的一个属性,意味着在箭头函数中无法使用这个属性,arguments.callee指向了arguments所属的函数对象。可以用在递归里,使函数名与函数逻辑解耦:

function fibonacci(num){
    if(num === 0 || num === 1){
        return num
    }
    return fibonacci(num - 1) + fibonacci(num - 2)
}
let fibonacciAlias = fibonacci
// 因为递归里用的是变量名fibonacci的函数名指针,所以置空后会报错
fibonacci = null
fibonacci(8) // Uncaught TypeError: fibonacci is not a function
fibonacciAlias(8) // Uncaught TypeError: fibonacci is not a function

如果使用arguments.callee的方式递归实现fabonacci数列,函数名与函数逻辑就可以解耦

function fibonacci(num){
    if(num === 0 || num === 1){
        return num
    }
    return arguments.callee(num - 1) + arguments.callee(num - 2)
}
let fibonacciAlias = fibonacci
// 因为递归里用的是arguments.callee,所以函数名与函数逻辑解耦了
fibonacci = null
fibonacci(8) // Uncaught TypeError: fibonacci is not a function
fibonacciAlias(8) // 21

caller

caller是Function上的属性,该属性引用的是调用当前函数的函数,如果是在全局作用域内调用的,则返回null

caller属性早期并没有写在ECMAScript3中,但是很多浏览器都实现了它

MDN中也提醒了caller是非标准的,在生产环境中药慎用!

function foo(){
    bar()
}
function bar() {
   if (bar.caller == null) {
      console.log("该函数在全局作用域内被调用!")
   } else{
      console.log("调用我的是函数是" + bar.caller)
   }
}
foo()
// 调用我的是函数是function foo(){
//     bar()
// }
bar() // 该函数在全局作用域内被调用!

同样,可以使用arguments.callee进行函数名和函数逻辑的解耦

function foo(){
    bar()
}
function bar() {
   if (arguments.callee.caller == null) {
      console.log("该函数在全局作用域内被调用!")
   } else{
      console.log("调用我的是函数是" + arguments.callee.caller)
   }
}
foo()
// 调用我的是函数是function foo(){
//     bar()
// }
bar() // 该函数在全局作用域内被调用!

this

在JS中,this是函数的内置的关键字,在严格模式和非严格模式下,this的指向会有一些差异,在标准函数和箭头函数中,this的指向也会有一些差异。

在任何函数外部时,无论严格模式与否,this都指向全局对象window:

console.log(this === window) //true
window.foo = 'foo'
console.log(this.foo) // foo
console.log(window.foo) // foo

在标准函数中,this指向的是调用函数的上下文对象,如果在全局上下文中调用时,this指向的是window,如果在严格模式下,则返回undefined:

function foo(){
    return this
}
function bar(){
    "use strict" // 严格模式
    return this
}
let obj = {
    foo: foo,
    bar: bar
}
console.log(foo() === window) // true
console.log(bar() === undefined) // true
console.log(obj.foo() === obj) // true
console.log(obj.bar() === obj) // true

在箭头函数中,this指向的是定义箭头函数时的上下文注意:箭头函数中的this会保存定义该函数时的上下文:

let foo = () => {
 return this
}
let obj = {
    foo: foo,
    bar(){
        return this
    }
}
console.log(foo() === window)
console.log(obj.foo() === window)
console.log(obj.bar() === obj)

标准函数和箭头函数的这种差异,可以解决在回调函数中的一些问题,例如:

function Foo(){
    this.msg = 'in foo'
    setTimeout(() => console.log(this.msg), 1000)
}
function Bar(){
    this.msg = 'in bar'
    setTimeout(function() { console.log(this.msg) }, 1000)
}
new Foo() // in foo
new Bar() // undefined

new.target

new.target属性可以用来检测一个函数是通过new关键字调用还是通过普通函数调用方式调用

在new关键字调用时,new.target返回指向原函数的引用,使用普通函数调用方式时,new.target返回undefined

值得注意的是,在new.target中,new不是一个真正的对象,而是一个虚拟的上下文,指向被new调用的构造函数

function Foo() {
    if (!new.target) throw "Foo() must be called with new";
    console.log(new.target);
}

Foo(); // Uncaught Foo() must be called with new
new Foo(); 
// Foo() {
//     if (!new.target) throw "Foo() must be called with new";
//     console.log(new.target);
// }

在类的构造方法中时,无论是在父类还是继承了父类的子类,new指向的都是被new调用的类的构造函数,看下面例子:

class Foo { constructor() { console.log(new.target.name); } }
class Bar extends Foo { constructor() { super(); } }

new Foo(); // Foo
new Bar(); // Bar

执行上下文(Execution Context)

本节建议对照ECMAScript (ECMA-262)原文阅读,欢迎评论交流和指出错误~

执行上下文是一种规范,用来跟踪记录JS代码运行时的状态。在任何时候,最多只有一个执行上下文在执行代码,这就是所谓的运行执行上下文(running execution context)。在ECMAScript规范中采用了这个数据结构来存储执行上下文,所以运行执行上下文始终处于栈顶位置,每当控制权从当前运行执行上下文相关联的代码转移到与当前运行执行上下文无关的代码时,就会创建一个新的执行上下文压入栈顶,并成为当前的运行执行上下文

当运行执行上下文中相关的代码执行完毕时,该执行上下文会从栈顶弹出,并且将控制权交回到下一个元素,此时运行执行上下文仍处于栈顶

在新的ECMAScript规范中,并没有把执行上下文按类型分类,而是引入了组件的概念(Components),在执行上下文创建时可能会包含许多组件,但是对于执行上下文中的JS代码,有两种类型的组件,分别是:

  • 词法环境组件(Lexical Environment):用于解析此执行上下文中标识符-对象的绑定
  • 变量环境组件(Variable Environment):标识词法环境,其环境记录(Environment Record)保存着此执行上下文中由var语句创建的标识符-变量的绑定

词法环境组件(Lexical Environment)

可以看一下ECMA-262中对词法环境的定义:

A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code. A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment. Usually a Lexical Environment is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a BlockStatement, or a Catch clause of a TryStatement and a new Lexical Environment is created each time such code is evaluated.

翻译一下,词法环境是一种规范,定义了标识符-变量/函数的关系,这里标识符指的是变量/函数的名称,变量/函数指的是对实际变量或函数对象的引用。词法环境由环境记录对外部词法环境的引用组成。什么时候会创建一个词法环境呢,即碰到函数声明语句块级区域catch语句的时候会创建一个新的词法环境。

在词法环境的组成部分中:

  • 环境记录(Environment Records):记录了该词法环境下标识符-变量/函数的绑定关系
  • 对外部词法环境的引用:当外部词法环境包裹着内部词法环境时,内部词法环境可以用过这个引用来访问到外部的词法环境

词法环境有三种类型:

  • 全局环境:全局环境是最顶层的词法环境,不可能再有更外部的词法环境了,所以它的外部词法环境引用为null。在全局环境的环境记录中会有一个全局对象(在浏览器中则为window)及一些内置的方法和用户自定义的全局变量,this会指向这个全局对象
  • 模块环境:模块环境包含着这个模块顶层声明的绑定关系,还包含了由模块显式导入的绑定关系,模块环境的外部环境引用指向全局环境
  • 函数环境:函数环境对应于ECMAScript中函数对象的调用,在函数环境中,会重新建立this的绑定,并且还会为调用super()方法提供支持

环境记录有三种类型:

  • 声明型环境记录:用来保存函数声明、变量声明和catch语句产生的绑定关系(与函数词法环境和模块词法环境对应)
  • 对象型环境记录:用来保存with语句产生的绑定关系
  • 全局环境记录:用来保存全局词法环境中出现的变量声明和函数声明(与全局词法环境对应)

可以把环境记录的分类看成一个简单的面向对象的层次结构中,其中环境记录是一个抽象类,有三个具体子类:声明型环境记录、对象型环境记录和全局环境记录。函数环境记录和模块环境记录是声明型环境记录的子类。抽象类包含了一些抽象规范方法。这些抽象方法对于每个具体子类都有不同的具体实现

变量环境组件(Variable Environment)

变量环境本身也是一个词法环境,在一个执行上下文创建的时候,词法环境和变量环境的初始值是一致的,区别是后期词法环境保存了函数声明和let、const的绑定关系,变量环境中保存了var的绑定关系

创建完成与执行

如果把执行上下文、词法环境、变量环境和环境记录都看成一个对象的话,则他们的关系可以抽象成这样:

ExectionContext = {  
  LexicalEnvironment: {
    type: 'Global',
    EnvironmentRecord: {  
      type: "Global",  
      // 标识符绑定关系 
    },
    outer: [null],  
  },
  LexicalEnvironment: {
    type: 'Global',
    EnvironmentRecord: {  
      type: "Global",  
      // 标识符绑定关系
    },
    outer: [null],  
  }
}

在执行时,配到变量取值时会现在环境记录中查找,找不到的话再通过outer到外部的词法环境中查找,如果最后找不到则为其分配undefined

在完成该执行上下文中相关代码的执行后,如果环境记录中的变量没有被引用,则可以随着该执行上下文一起退栈销毁

函数闭包

上一节说到词法环境是可以嵌套的,由环境记录和对外部词法环境的引用组成,如果一个内部函数通过该引用访问到了外部函数词法环境的作用域,这样的组合就是一个闭包,可以看下面例子:

let msg = 'in window'
function foo(){
    let msg = 'in foo'
    return function(){
        console.log(msg)
    }
}
let bar = foo()
bar() // in foo

闭包可以用来解决很多问题,例如防抖、节流和用闭包模拟私有方法等,但是滥用闭包也有可能会造成性能问题,例如上面例子中的foo函数的环境记录中的变量引用并不能随着代码的执行完毕而销毁,因为bar函数仍然引用其环境记录中的msg变量,这样就会造成许多的内存碎片影响性能,我们可以这样做来解除引用:

let msg = 'in window'
function foo(){
    let msg = 'in foo'
    return function(){
        console.log(msg)
    }
}
let bar = foo()
bar() // in foo
// 解除所有对内部函数的引用,内部函数的词法环境会被GC销毁回收,
// 从而使对外部函数词法环境的引用解除了,自然也可以被GC销毁回收了
bar = null

递归与尾调用优化

递归

递归是一种函数调用自身的操作,递归一般用来处理包含有更小的子问题一类问题。例如求fibonacci数列的实现:

function fibonacci(num){
    if(num === 0 || num === 1){
        return num
    }
    return fibonacci(num - 1) + fibonacci(num - 2)
}

每轮递归都会创建一个执行上下文进栈,执行栈的容量不是无限的,当超过栈的容量上限之后还继续进栈的话,就会报stack overflow的错误:

fibonacci(1000) // 已经开始卡
fibonacci(10000) // Uncaught RangeError: Maximum call stack size exceeded

为了解决这种问题,在某种情况下,我们可以使用尾调用优化来优化这个代码

尾调用优化

尾调用的意思是函数的最后一步返回一个函数的运行结果,且不能是闭包,例如:

// 正确
function foo(param){
    if(param > 0){
        return bar(param)
    }
    return bar(0)
}
// 错误
function foo(){
    let innerParam = 1
    return bar(innerParam) // 不能引用内部变量
}

之所以可以做尾调用优化,是因为它与一般的函数不同,由于是函数最后一步操作,并且不需要用到外部函数的变量,所以可以将外部函数的执行上下文直接销毁,只保留内部函数的执行上下文即可,这样,无论递归函数的递归层次有多深,执行栈中始终只保留最内部一层的函数的执行上下文,这样,无论如何也不会出现stack overflow的错误了

所以,我们可以将上面的fabonacci函数修改成符合尾调用优化的实现方式:

function fibonacci(a, b, n){
    if(n === 0 || n === 1){
        return a
    }
    return fibonacci(b, a + b, n - 1)
}
fibonacci(0, 1, 1000) // 2.686381002448534e+208
fibonacci(0, 1, 10000) // 超过机器数能表示的上限

目前主流的浏览器(chrome/safari等)或NodeJs最新版本均已按照ECMA-262规范实现尾调用优化,所以,只要我们写出符合尾调用优化标准的代码,在执行前JS引擎就会自动帮我们进行优化

以上是对函数的一些理解和梳理,欢迎交流~