跳至主要內容

函数

Mr.LRH大约 18 分钟

函数

函数概述

每个函数都是 Function 类型的实例,而 Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。

由于函数与其他数据类型地位平等,所以在 JavaScript 语言中又称函数为第一等公民。比如,可以把函数赋值给变量和对象的属性,也可以当作参数传入其他函数,或者作为函数的结果返回。函数只是一个可以执行的值,此外并无特殊之处。

函数的声明

// function 命令
function namedFunction(x, y) { return x + y }

// 函数表达式
// 函数表达式声明函数时,如果 function 命令后不带有函数名
let expression = function (x, y) { return x + y }
// 函数表达式声明函数时,如果 function 命令后带有函数名
// > 函数名仅在函数体内部有效,可在函数体内部调用自身
// > 方便排查错误。显示函数调用栈时,将显示函数名
let expression = function expression(x, y) {
  console.log(typeof expression) // function
  return x + y
}

// Function 构造函数 - 不推荐使用
// 使用 Function 构造函数声明函数会被解释两次,会影响性能。
// > 第一次,将其当作常规 ECMAScript 代码
// > 第二次,解释传给构造函数的字符串
let newFunction = new Function('x', 'y', 'return x + y')
// 等同于 function newFunction(x, y) { return x + y }
  • 函数声明提升:函数声明会在任何代码执行之前先被读取并添加到执行上下文。在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。

  • 函数重复声明:如果同一个函数被多次声明,后面的声明会覆盖前面的声明。

  • 函数返回:函数体内部的 return 语句,表示返回。如果没有的话,该函数不返回任何值,或者说返回 undefined

  • 函数没有重载,可以借助函数对的 length 属性(返回函数预期传入的参数个数,即函数定义之中的参数个数)以便实现面向对象编程的“方法重载”(overload)。

    // 使用 function 命令
    namedFunction() // 函数声明提升,不会报错
    function namedFunction(x, y) { return x + y }
    
    // 使用函数表达式
    expression() // 报错: Uncaught TypeError: expression is not defined
    var expression = function (x, y) { return x + y }
    // 等同于
    // var expression
    // expression()
    // expression = function (x, y) { return x + y }
    
    // 使用 function 命令和 var 赋值语句声明同一个函数时,
    // 因为存在函数提升,最后会采用 var 赋值语句的定义
    var expression = function () { return 'expression' }
    function expression() { return 'function expression' }
    expression() // 'expression'
    

函数的属性和方法

  • name 属性 :返回函数的函数名。

    function namedFunction(x, y) { return x + y }
    namedFunction.name // 'namedFunction'
    
    var expression = function (x, y) { return x + y }
    expression.name // '' ,ES5 环境下返回空字符串
    expression.name // 'expression' ,ES6 环境下返回具名函数名
    
    let expression = function expression(x, y) { return x + y}
    expression.name // 'expression' ,ES5 环境下返回具名函数名
    expression.name // 'expression' ,ES6 环境下返回具名函数名
    
    // Function 构造函数返回的函数实例,name 属性的值为 anonymous
    (new Function).name // "anonymous"
    
    // bind 返回的函数,name 属性值会加上 bound 前缀
    function namedFunction(x, y) { return x + y }
    namedFunction.bind({}).name // 'bound namedFunction'
    
    (function(){}).bind({}).name // 'bound '
    
  • length 属性 :返回函数预期传入的参数个数,即函数定义之中的参数个数。

    length 属性可以用于判断定义时和调用时参数的差异,以便实现面向对象编程的“方法重载”(overload)。

    function namedFunction(x, y) { return x + y }
    namedFunction.length // 2
    
  • toString() 方法 :返回函数源码的字符串(包含换行符、注释在内)。

    function namedFunction(x, y) { return x + y }
    namedFunction.toString() // 'function namedFunction(x, y) { return x + y }'
    
    // 原生函数,返回 'function (){[native code]}'
    Math.sqrt.toString() // 'function sqrt() { [native code] }'
    
  • call(thisArg[, arg1, arg2, ...argN]) 方法 :调用一个函数,并将其 this 值设置为提供的值。

  • apply(thisArg [, argsArray]) 方法 :调用一个函数并将其 this 的值设置为提供的 thisArg。参数可用以通过数组对象传递。

  • bind(thisArg[, arg1[, arg2[, ...argN]]]) 方法 :创建一个新的函数,该函数在调用时,会将 this 设置为提供的 thisArg

函数参数

ECMAScript 函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含的值。参数数组为空或者数组元素超过函数要求,都不会有影响。

函数参数不是必需的,JavaScript 允许省略参数。但是没有办法只省略靠前的参数,而保留靠后的参数。如果一定要省略靠前的参数,只有显式传入 undefined

function namedFunction(x, y) { return [x, y] }

namedFunction(1, 2, 3) // [1, 2]
namedFunction(1, 2) // [1, 2]
namedFunction(1) // [1, undefined]
namedFunction() // [undefined, undefined]
namedFunction(undefined, 1) // [undefined, 1]

函数如果有同名的参数,则取最后出现的那个值。如果使用 arguments 对象,则可以获取对应的值。

function namedFunction(x, x) { return [x, x, arguments[0]] }
namedFunction(1, 2) // [2, 2, 1]
namedFunction(1) // [undefined, undefined, 1]

arguments 对象

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

  • arguments 对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的元素,通过 arguments.length 可以获取参数个数。但无法使用数组专有的方法,需要转换成真正的数组。

    let args = Array.prototype.slice.call(arguments)
    
    // 或者
    
    let args = []
    for (var i = 0; i < arguments.length; i++) {
      args.push(arguments[i])
    }
    
  • arguments 对象可以跟函数命名参数一起使用。arguments 对象的值始终会与对应的命名参数同步,它们在内存中还是分开的,只不过会保持同步而已。严格模式下, arguments 对象与函数参数不具有联动关系。

  • 如果只传了一个参数,将 arguments[1] 设置为某个值,这个值并不会反映到第二个命名参数。因为 arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。

  • arguments 对象存在一个 callee 属性,返回它所对应的原函数。通过 arguments.callee达到调用函数自身的目的。这个属性在严格模式里面是禁用的,因此不建议使用。

使用箭头函数时,传给函数的参数不能使用 arguments 关键字访问,只能通过定义的命名参数访问。

reset 参数(剩余参数)

reset 参数(剩余参数,形式为 ...变量名)用于获取传入函数的多余参数,并组成一个数组。

function foo(x, ...resetArr) { return resetArr }
foo(1, 2, 3) // [2, 3]

// 使用 rest 参数代替 arguments 对象示例
function foo(...resetArr) { return resetArr }
foo(1, 2, 3) // [1, 2, 3]

// 与解构赋值默认值结合使用
function f(...[a, b, c]) {
  return [a, b, c]
}
foo(1, 2, 3) // [1, 2, 3]

reset 参数与 arguments 对象的区别:

  • reset 参数只包含没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参。
  • reset 参数是一个真实的 Array 实例,arguments 对象是类数组对象
  • arguments 对象存在一些附加的属性(如 callee 属性)

设置参数默认值

在 JavaScript 中,函数参数的默认值是 undefined。函数默认参数允许在没有值或 undefined 被传入时使用默认形参。

  • 参数变量是默认声明的,所以不能用 letconst 再次声明。
  • 使用参数默认值时,函数不能有同名参数。
  • 参数默认值不是在定义时执行,而是在运行时执行计算默认值表达式的值。如果参数已赋值,默认值中的函数就不会执行。
  • 参数传入 undefined,将触发该参数等于默认值,null 则没有这个效果。
  • 参数默认值设为 undefined,表明这个参数是可以省略的.
// ES5 之前,设置函数参数默认值
function log(x, y) {
  y = (typeof y !== 'undefined') ? y : 0
  return [x, y]
}
log(1) // [1, 0]
log(1, 2) // [1, 2]

// ES6 设置函数参数默认值
function log(x, y = 0) {
  return [x, y]
}
log(1) // [1, 0]
log(1, 2) // [1, 2]

// 参数变量是默认声明的,所以不能用 let 或 const 再次声明。
function log(x = 0) {
  let x = 1 // 报错。Uncaught SyntaxError: Identifier 'x' has already been declared
  const x = 2 // 报错。Uncaught SyntaxError: Identifier 'x' has already been declared
}

// 使用参数默认值时,函数不能有同名参数。
function foo(x, x, y = 1) {}
// Uncaught SyntaxError: Duplicate parameter name not allowed in this context
function log(x, x, y) {} // 不报错

// 参数默认值不是在定义时执行,而是在运行时执行计算默认值表达式的值。如果参数已赋值,默认值中的函数就不会执行。
let x = 99
function foo(val = x + 1) {
  return val
}
foo() // 100
x = 100
foo() // 101

// 传入 undefined,将触发该参数等于默认值,null 则没有这个效果。
function foo(x = 5, y = 6) {
  return [x, y]
}
foo(undefined, null) // [5, null]

// 参数默认值设为 undefined,表明这个参数是可以省略的
function foo(optional = undefined) {}

函数设置默认值与解构赋值默认值结合使用

function foo({ x = 0, y = 0 } = {}) {
  return [x, y]
}

function bar({ x, y } = { x: 0, y: 0 }) {
  return [x, y]
}

// 函数没有参数的情况
foo() // [0, 0]
bar() // [0, 0]

// x 和 y 都有值的情况
foo({ x: 3, y: 8 }) // [3, 8]
bar({ x: 3, y: 8 }) // [3, 8]

// x 有值,y 无值的情况
foo({ x: 3 }) // [3, 0]
bar({ x: 3 }) // [3, undefined]

// x 和 y 都无值的情况
foo({}) // [0, 0];
bar({}) // [undefined, undefined]

foo({ z: 3 }) // [0, 0]
bar({ z: 3 }) // [undefined, undefined]

函数指定了默认值以后,函数的 length 属性(返回该函数预期传入的参数个数),将返回没有指定默认值的参数个数。

  • 某个参数指定默认值以后,预期传入的参数个数就不包括该参数。
  • rest 参数(剩余参数)不会计入 length 属性。
  • 如果设置了默认值的参数不是尾参数,length 属性不再计入后面的参数个数。
// 某个参数指定默认值以后,预期传入的参数个数就不包括该参数
(function (a) {}).length // 1
(function (a = 5) {}).length // 0
(function (a, b, c = 5) {}).length // 2

// rest 参数(剩余参数)不会计入 length 属性。
(function(...args) {}).length // 0

// 如果设置了默认值的参数不是尾参数,length 属性不再计入后面的参数个数。
(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1

利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。

function throwIfMissing() {
  throw new Error('Missing parameter')
}

function foo(mustBeProvided = throwIfMissing()) {
  return mustBeProvided
}

foo()

值传递

  • 如果函数参数是原始类型的值(数值、字符串、布尔值),传递方式是传值传递(passes by value)。在函数体内修改参数值,不会影响到函数外部。
  • 如果函数参数是引用类型的值(数组、对象、其他函数),传递方式是传址传递(pass by reference)。传入函数的原始值的地址,因此在函数内部修改参数,将会影响到原始值。

注意,如果函数内部修改的,不是参数对象的某个属性,而是替换掉整个参数,此时不会影响到原始值。

let fooVal = 1
function foo(val) { val = 2 }
foo(fooVal)
console.log(fooVal) // 1

let fooObj = { foo: 1 }
function foo(val) { val.foo = 2 }
foo(fooObj)
console.log(fooObj.foo) // 2

let fooArr = [1, 2, 3]
function foo(val) { val = [4, 5, 6] }
foo(fooArr)
console.log(fooArr) // [1, 2, 3]

箭头函数

ES6 允许使用“箭头”(=>)定义函数。

let foo = () => 'foo'
let foo = val => val
let foo = (x, y) = x + y
let foo = ({ key, value }) => { key, value }

let foo = id => { id: id, name: 'foo' } // 报错
let foo = id => ({ id: id, name: 'foo' }) // 正常执行

// 执行函数没有返回值,或者说返回为 undefined
// 函数意图返回一个对象,由于引擎任务大括号是代码块,所以执行了 name: 'foo' ,
// 此时 name 可以被解释为语句标签,英因此实际执行语句为 'foo' ,然后函数执行结束,没有返回值。
let foo = () => { name: 'foo' }

let formatArr = [1, 2, 3].map(val => val * val)
let sortArr = [3, 2, 1].sort((a, b) => a - b)

let foo = (x, ...rest) => [x, rest]
foo(1, 2, 3, 4, 5) // [1, [2, 3, 4, 5]]

使用箭头函数注意事项:

  • 箭头函数没有自己的 this 对象。箭头函数内部的 this 是定义时上层作用域中的 this

    由于箭头函数没有自己的 this ,不能使用 call()apply()bind() 方法改变 this 的指向。

    function Timer() {
      this.s1 = 0
      this.s2 = 0
    
      // 箭头函数
      // this 指向定义时所在的作用域(即 Timer 函数)
      setInterval(() => this.s1++, 1000)
    
      // 普通函数
      // this 指向运行时所在的作用域(即全局对象)
      setInterval(function () {
        this.s2++
      }, 1000)
    }
    
    var timer = new Timer()
    
    setTimeout(() => console.log('s1: ', timer.s1), 3100)
    setTimeout(() => console.log('s2: ', timer.s2), 3100)
    
    // s1: 3
    // s2: 0
    
  • 在箭头函数之中不存在的指向外层函数的对应变量:argumentssupernew.target。如果要使用 arguments 对象,可以使用 rest 参数代替。

  • 不可以当作构造函数使用。不可以对箭头函数使用 new 命令,否则会抛出一个错误。

  • 不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。

不适合使用箭头函数的场景:

  • 定义对象的方法,且该方法内部包括 this,不应使用箭头函数。对象的属性建议使用传统的写法定义。
  • 需要动态 this 的时候,不应使用箭头函数。
  • 函数体复杂,或者函数内部有大量的读写操作,不单纯是为了计算值,不应使用箭头函数,使用普通函数,可以提高代码可读性。

递归

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

arguments 对象上的属性 callee 指向正在执行的函数的指针,可以在函数内部递归调用。

在严格模式下,arguments.callee 不能访问,可以使用命名函数表达式。

// 使用 arguments.callee 进行递归调用
function factorial(num) {
  if (num <= 1) {
    return 1
  } else {
    return num * arguments.callee(num - 1)
  }
}

// 使用函数名进行递归调用
function factorial(num) {
  if (num <= 1) {
    return 1
  } else {
    return num * factorial(num - 1)
  }
}

// 箭头函数进行递归
let factorial = x => (x == 0 ? 1 : x * factorial(x - 1))

闭包

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

  • 读取外层函数内部的变量,并将这些变量始终保持在内存中。

    闭包能够返回外层函数的内部变量的原因:

    • 闭包用到了外层变量,导致外层函数不能从内存释放。
    • 只要闭包没有被垃圾回收机制清除,外层函数提供的运行环境也不会被清除,它的内部变量就始终保存着当前值,供闭包读取。
    function createIncrementor(start) {
      return function () {
        return start++
      }
    }
    
    var inc = createIncrementor(5)
    
    inc() // 5
    inc() // 6
    inc() // 7
    
  • 封装对象的私有属性和私有方法。

    function Person(name) {
      var _age
      function setAge(n) {
        _age = n
      }
      function getAge() {
        return _age
      }
    
      return {
        name: name,
        getAge: getAge,
        setAge: setAge,
      }
    }
    
    var p1 = Person('张三')
    p1.setAge(25)
    p1.getAge() // 25
    

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

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

  • (function(){ /*code*/ }());
  • (function(){ /*code*/ })();

注:两种写法最后的分号都是必须的。如果省略分号,遇到连着两个 IIFE,可能就会报错。

let foo = function foo() { return 'foo' }()
foo // 'foo'

(function(){ return 'foo' }()); // 'foo'

(function(){ return 'foo' })(); // 'foo'

函数定义后立即调用的方法就是不要让 function 出现在行首,让引擎将其理解成一个表达式。则立即调用的函数表达式可以有以下写法。

true && function(){ /* code */ }();
0, function(){ /* code */ }();
!function () { /* code */ }();
~function () { /* code */ }();
-function () { /* code */ }();
+function () { /* code */ }();

使用立即调用的函数表达式,主要目的:

  • 不必为函数命名,避免污染全局变量
  • IIFE 内部形成一个单独的作用域,可以封装一些外部无法访问的私有变量
(function () {
  var tmp = newData;
  processData(tmp);
  storeData(tmp);
}());

尾调用优化

尾调用概述

尾调用:外部函数的返回值是一个内部函数的返回值。即某个函数的最后一步是调用另一个函数。

function outerFunction() {
  return innerFunction() // 尾调用
}
  • 在 ES6 优化之前,执行示例会在内存中发生如下操作:
    • 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
    • 执行 outerFunction 函数体,到 return 语句。计算返回值必须先计算 innerFunction
    • 执行到 innerFunction 函数体,第二个栈帧被推到栈上。
    • 执行 innerFunction 函数体,计算其返回值。
    • 将返回值传回 outerFunction,然后 outerFunction 再返回值。
    • 将栈帧弹出栈外。
  • 在 ES6 优化之后,执行这个例子会在内存中发生如下操作。
    • 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
    • 执行 outerFunction 函数体,到达 return 语句。为求值返回语句,必须先求值 innerFunction
    • 引擎发现把第一个栈帧弹出栈外也没问题,因为 innerFunction 的返回值也是 outerFunction 的返回值。
    • 弹出 outerFunction 的栈帧。
    • 执行到 innerFunction 函数体,栈帧被推到栈上。
    • 执行 innerFunction 函数体,计算其返回值。
    • innerFunction 的栈帧弹出栈外。

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

  • 代码在严格模式下执行。

    要求严格模式主要因为在非严格模式下函数调用中允许使用 f.argumentsf.caller,而它们都会引用外部函数的栈帧。意味着不能应用优化了,因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

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

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

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

// 有优化:栈帧销毁前执行参数计算
function outerFunction(a, b) {
  return innerFunction(a + b)
}

// 有优化:尾调用不一定出现在函数尾部,只要是最后一步操作即可
function outerFunction(x) {
  if (x > 0) {
    return innerFunctionA(x)
  }
  return innerFunctionB(x)
}

// 有优化:两个内部函数都在尾部
function outerFunction(condition) {
  return condition ? innerFunctionA() : innerFunctionB()
}

// 无优化:尾调用没有直接返回
function outerFunction() {
  let innerFunction = innerFunction()
  return innerFunction
}

// 无优化:尾调用返回后必须转型为字符串
function outerFunction() {
  return innerFunction().toString()
}

// 无优化:尾调用是一个闭包
function outerFunction() {
  let foo = 'bar'
  function innerFunction() { return foo }
  return innerFunction()
}

// 无优化:尾调用返回好进行计算
function outerFunction() {
  return innerFunction() + 1
}

// 无优化:尾调用没有返回
function outerFunction() {
  innerFunction()
  // 相当于
  // innerFunction()
  // return undefined
}

尾递归

尾递归:函数尾调用自身。

递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。

尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。

// 阶乘函数时间复杂度为 O(n)
function factorial(n) {
  if (n === 1) return 1
  return n * factorial(n - 1)
}

// 使用函数默认值改写阶乘函数,确保最后一步只调用自身
// 使用尾调用优化,阶乘函数时间复杂度为 O(1)
function factorial(n, total = 1) {
  if (n === 1) return total
  return factorial(n - 1, n * total)
}

// 使用柯里化改写阶乘函数,确保最后一步只调用自身
// 使用尾调用优化,阶乘函数时间复杂度为 O(1)
// 注:柯里化(currying)将多参数的函数转换成单参数的形式
function currying(fn, n) {
  return function (m) {
    return fn.call(this, m, n)
  }
}
function tailFactorial(n, total) {
  if (n === 1) return total
  return tailFactorial(n - 1, n * total)
}
const factorial = currying(tailFactorial, 1)

尾递归优化的实现

  • 蹦床函数(trampoline):将递归执行转为循环执行。

    • 接受一个 函数 f 作为参数。只要 函数 f 执行后返回一个函数,则继续执行。
    • 返回一个函数,然后执行该函数,而不是函数里面调用函数。避免了递归执行,从而就消除了调用栈过大的问题。
    function trampoline(f) {
      while (f && f instanceof Function) {
        f = f()
      }
      return f
    }
    
    // 示例
    function sum(x, y) {
      if (y > 0) {
        return sum.bind(null, x + 1, y - 1)
      } else {
        return x
      }
    }
    trampoline(sum(1, 100000))
    
  • 非严格模式下,尾递归优化真实实现

    • 设置状态变量 active。默认情况下,变量是不激活的;进入尾递归优化的过程,该变量就会激活。
    • 每一轮递归函数返回的都是 undefined ,避免了递归执行。
    • accumulated 数组存放每一轮函数执行的参数,总是有值的,保证了 accumulator 函数内部的 while 循环总是会执行。
    • 巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。
    function tco(f) {
      var value
      var active = false
      var accumulated = []
    
      return function accumulator() {
        accumulated.push(arguments)
        if (!active) {
          active = true
          while (accumulated.length) {
            value = f.apply(this, accumulated.shift())
          }
          active = false
          return value
        }
      }
    }
    
    // 示例
    var sum = tco(function (x, y) {
      if (y > 0) {
        return sum(x + 1, y - 1)
      } else {
        return x
      }
    })
    
    sum(1, 100000)
    
上次编辑于:
贡献者: lingronghai