函数是“一等对象”

函数式编程的风格是将函数组合起来,而不是像命令式语言那样指定一系列步骤;理解 JavaScript 中包含的函数式语言的特性至关重要。

JavaScript 中的函数是一等对象(first-class objects, first-class citizens),函数作为一种,可以和任何其他类型的值一样被使用。

JavaScript 中对象的特性:

  1. 可以通过字面量 {} 创建;
  2. 可以赋值给变量、数组元素、对象的属性;
  3. 可以作为参数传递给函数(回调函数);
  4. 可以作为函数的返回值返回;
  5. 拥有属性,可以动态创建属性。

JavaScript 的函数也是对象,同样拥有上述特性,同时具有可以被调用的特殊属性。

function foo() {}

var f = function() {}
arr.push(function() {})
obj.data = function() {}

function call(foo) { foo() }
call(function() {})

function returnFunc() { return function() {} }

var func = function() {}
func.name = "foo"

任何可以出现表达式的地方都可以创建函数。

  • 这样的写法紧凑、易懂(函数的定义的就在调用函数的地方)。

  • 若函数不会在多处被引用,也可以避免定义一个全局函数名污染全局命名空间。

    function foo(cb) { return cb() }
    console.log(foo(function() { return "synchronous callback" }))
    /*
    function cb() { return "synchronous callback2" }
    console.log(foo(cb)) // 传入函数名
    */
    console.log("end")
    
    document.body.addEventListener("click", function() { console.log("asynchronous callback invoked by the browser") })
    

数组排序的函数式写法中,我们不把决定顺序先后的逻辑交由排序算法(这部分逻辑在每个场景下都是不同的),而是提供一个执行比较操作的回调函数,供排序算法调用;回调函数返回正数表示比较的两个值需要交换顺序,返回负数表示不需要交换,返回 0 表示两个值相等。

var arr = [0, 3, 2, 5, 7, 4, 8, 1]
arr.sort(function(v1, v2) { return v1 - v2 }) // 升序
console.log(arr) 

实现一个存储回调函数的集合

var store = {
    nextId: 1,
    cache: {},
    add: function(fn) {
        if (!fn.id) { // *
            fn.id = this.nextId++
            this.cache[fn.id] = fn
            return true
        }
    }
}
function foo() {}
store.add(foo)

实现一个具有“记忆”功能的函数,避免一些重复的耗时计算:

function isPrime(value) {
    if (!isPrime.answers) {
        isPrime.answers = {} // create the cache
    }
    if (isPrime.answers[value] !== undefined) {
        return isPrime.answers[value]
    }
    var prime = value !== 1 // 1 不是质数
    for (var i = 2; i < value; i++) { // ignore the inefficient algorithm 
        if (value % i === 0) {
            prime = false
            break
        }
    }
    return isPrime.answers[value] = prime
}
console.log(isPrime(5))

这样写也存在一些问题:

  • 缓存是以空间换性能,需根据实际需求进行选择。
  • 缓存和函数的逻辑混杂在一起,函数应该做到职责单一。
  • 函数的性能会受到之前输入参数的影响,导致不易测试。

定义函数

定义函数的方法可以分为四类:

  1. 函数声明和函数表达式;
  2. 箭头函数(也叫 lambda functions);
  3. 函数构造器
    • 动态创建、解析代码时有应用。
  4. Generator 函数。

函数定义和函数表达式

函数声明必须作为一个独立的语句出现,它可以出现在另一个函数里(函数式特点)或代码块中。

函数必须要有函数名,否则无法被调用(引用不到)。

函数表达式总是作为一个语句的一部分出现;函数名可选。

调用函数的 () 前面可以是任何能解析成一个函数的表达式,所以有了 IIFE(immediately invoked function expression)的写法。

  • IIFE 中的函数表达式要包在括号里,指示 JavaScript 解析器将它作为表达式而不是语句解析(一元操作符也可以起到这种效果)。

    • 不加括号的话,以 function 开头的代码会被作为函数声明来解析,而函数声明必须有函数名,会报错。
  • IIFE 可以用来模拟模块

    function foo() {
        function innerFoo() {}
    }
    
    var myFunc = function() {}                  // * function() {} 是函数表达式
    (function namedFunctionExpression() {})()   // 具名函数表达式
    
    function() {console.log("hi")}()        // Uncaught SyntaxError: Function statements require a function name
    function foo() {console.log("hi")}()    // Uncaught SyntaxError: Unexpected token ')'
    
    +function(){}()
    -function(){}()
    !function(){}()
    ~function(){}()
    

箭头函数

箭头函数的标志是胖箭头(fat-arrow)操作符 =>

箭头函数语法:

  • 只有一个参数时,参数可以不用括号包裹。
  • 函数体只有一个表达式时,返回值就是该表达式的值,可以显式写 return 语句。
  • 函数体是代码块时,return 语句和普通函数的写法相同;省略 return 语句则返回 undefined

实参和形参

参数(parameter,形参)是函数定义中列出的变量。

实参(argument)是调用函数时传递过去的

实参会按传入的顺序和形参一一对应,两者数量不一致时不会报错,多出的参数(没有对应的实参传入)的值是 undefined

ES6 引入了剩余参数(rest parameter)的语法 ...

  • 剩余参数是一个数组,只能作为最后一个参数,否则会报语法错误。

    function multiMax(first, ...remaining) {
        var sorted = remaining.sort(function(v1, v2) { return v2 - v1 })
        return first * sorted[0]
    }
    console.log(multiMax(3, 1, 2, 3)) // 9
    

JavaScript 不支持函数重载(函数名相同,函数签名不同)。

  • 对于 JavaScript 而言,函数签名就是参数个数。

ES6 引入默认参数语法。

  • 由于参数的解析顺序为从左往右,写在后面的默认参数可以引用写在前面的参数。

    function foo1(a, b = "default") {
        return a + b
    }
    function foo2(a, b = "default", msg = a + b) { // 不建议这样写
        return msg
    }
    
    // pre ES6
    function foo3(a, b) {
        b = typeof action === "undefined" ? "default" : action
        return a + b
    }
    

隐式参数

函数体里可以访问到两个隐式传入(不在函数签名里)的参数:thisarguments

  • this函数上下文(function context),表示函数是在哪个对象上被调用的
  • arguments对象)代表传入函数的所有实参,包括函数签名里没有指定形参的实参
    • argumentslength 属性的值是实参的个数,里面的实参可以通过数组下标的形式访问。

    • arguments 结构和数组类似,但它不是数组,不能在 arguments 身上使用数组方法(如 sort)。

      • 剩余参数是真数组。
    • arguments 是函数参数的别名,它们指向相同的地址。

      • 若更改 arguments 中“元素”的值,对应形参的值也会随之改变;反之,改变形参的值也会改变 arguments 中对应“元素”的值。
      (function foo(a) {
          var save = a
          arguments[0] = "bar"
          console.log(a)              // bar
          a = save
          console.log(arguments[0])   // foo
      })("foo")
      
      • 严格模式下,arguments 就不是函数参数的别名。
      "use strict";
      (function foo(a) {
          var save = a
          arguments[0] = "bar"
          console.log(a)              // foo
          a = save
          console.log(arguments[0])   // bar
      })("foo")
      

函数的调用

1. 作为函数被直接调用

非严格模式下(全局代码里)函数被直接调用时,它的 this 是全局上下文,即 window 对象。

// * 全局代码里的 this 就是 window。
// 可以理解成通过一个全局对象构造器创建了一个全局对象单例 window,this 自然指向 window,
// 全局函数的调用实际上是调用 window 对象的方法,所以它们的 this 都指向 window
console.log(this === window) // true
function foo() { console.log("foo") }
foo()           // foo
// window “拥有”所有全局变量和全局函数
window.foo()    // foo

严格模式下(全局代码里)函数被直接调用时,它的 thisundefined

2. 作为对象的方法被调用

函数作为对象的方法被调用时,this 是“拥有”该方法的对象。

function myContext() {
    return this
}
console.log(myContext() === window)     // true
var getMyContext = myContext            // 创建对 myContext 函数的一个引用,并没有创建函数的新实例
console.log(getMyContext() === window)  // true
var obj1 = {
    getMyContext: myContext             // getMyContext 属性的值是对 myContext 函数的一个引用,myContext 本身没有变成 obj1 的一个方法
}
console.log(obj1.getMyContext() === obj1) // true
var obj2 = {
    getMyContext: myContext
}
console.log(obj2.getMyContext() === obj2) // true

两个对象需要共用相同的逻辑时,不需要各自定义方法,只需要指向同一个函数,函数作为不同对象的方法被调用时,就能通过 this 访问到方法所属的对象。

3. 作为构造函数被调用

构建函数是用来创建和初始化对象实例的函数(不应被用作其它用途)。

  • 若函数作为构造函数使用,调用时要加上 new 关键字,除此之外构造函数和普通函数并无差别。
  • 构造函数不是函数构造器。
    • 函数构造器可以用字符串构建函数(new Function("a", "b", "return a + b"))。

调用构造函数时发生的动作:

  1. 创建一个空对象;

  2. 创建的空对象被传入构造函数,并作为构造函数的 this

  3. 新创建的对象作为返回值被返回(构造函数显式返回其它值时是例外)。

    function Door() {
        this.context = function() {
            return this
        }
    }
    var door1 = new Door()
    var door2 = new Door()
    console.log(door1.context() === door1) // true
    console.log(door2.context() === door2) // true
    
    • 若构造函数显式返回的是一个对象,则该对象会成为 new 表达式的值,新创建的对象被丢弃;若显式返回的不是对象,则该值被丢弃,新创建的对象照常返回。

      function Board() {
          this.context = function() {
              return true
          }
          return 1
      }
      console.log(Board() === 1) // true
      const board = new Board()
      console.log(typeof board === "object") // true
      console.log(typeof  board.context === "function") // true
      
      var s = { price: 1230 }
      function Stick() {
          this.price = 123
          this.context = function() {
              return this
          }
          return s
      }
      var stick = new Stick()
      console.log(stick === s, stick.price) // true, 1230
      console.log(stick.context()) // Uncaught TypeError: stick.context is not a function
      

构造函数的用途决定了它的内部代码实现和普通函数不同,带来的结果是将构造函数作为一般函数使用时基本无用,于是有了区分构造函数和普通函数的命名规范

  • 函数名和方法名通常以动词开头,描述行为;首字母小写。
  • 构造函数名通常是描述对象的名词;首字母大写。

构造函数提供了创建遵循同一种模式的对象的模板。

4. 通过函数的 applycall 方法被调用

函数的 applycall 方法可以在调用函数时显式指定一个对象(第一个参数)作为函数上下文 this

applycall 只有参数传递写法上的差别,函数体里通过 arguments 访问传入参数的方式相同。

function sum() {
    var result = 0
    for (var n = 0; n < arguments.length; n++) {
        result += arguments[n]
    }
    this.result = result
}
var o1 = {}
var o2 = {}
sum.apply(o1, [1, 2, 3, 4])
sum.call(o2, 5, 6, 7, 8)
console.log(o1.result, o2.result) // 10 26

简易版 forEach 的实现:

function forEach(arr, cb) {
    for (var n = 0; n < arr.length; n++) {
        cb.call(arr[n], n)
    }
}
var letters = [{ name: "a" }, { name: "b" }, {name: "c"}]
forEach(letters, function(index) {
    console.log(this === letters[index]) // true true true
})

捋顺函数上下文

箭头函数没有自己的函数上下文,它的 this 值是函数定义时“记住”的那个包含箭头函数定义的外层环境的 this

  • 若箭头函数定义在构造函数里,它的 this 是新创建的对象。

  • 若箭头函数定义在全局的对象字面量里,它的 thiswindow 对象。

    const foo = () => console.log(this === window)
    foo() // true
    (() => console.log(this === window))() // true
    

函数的 bind 方法会返回一个新函数(不会修改原函数),新函数的函数体和原函数的相同,但新函数的上下文 this 永远指向 bind 的第一个参数指定的对象,无论新函数如何被调用,this 都不会变。

<button id="test1">Click1</button>
<button id="test2">Click2</button>
<button id="test3">Click2</button>
<script>
  function Button1() {
      this.clicked = false
      this.click = function() {
          this.clicked = true
          console.log("button1 clicked")
      }
  }
  var button1 = new Button1()
  var ele1 = document.querySelector("#test1")
  // 事件处理器的回调函数的 this 指向注册且发生了该事件的 DOM 元素,
  // 此处即 DOM 中的按钮,不是 button 对象
  ele1.addEventListener("click", button1.click)
  // clicked 状态没有设置在 button1 对象上(log after click)
  // button1: {clicked: false, click: ƒ} 

  function Button2() {
      this.clicked = false
      this.click = () => {
          this.clicked = true
          console.log("button2 clicked")
      }
  }
  var button2 = new Button2()
  var ele2 = document.querySelector("#test2")
  ele2.addEventListener("click", button2.click)
  // log after click
  // button2: {clicked: true, click: ƒ}

  // bind 解法
  // ele1.addEventListener("click", button.click.bind(button))
</script>