JavaScript:运行时的函数

一级函数

函数是一级函数

在 JavaScript 中,函数是一级函数。这意味着就像对象一样可以像处理其他元素(如数字、字符串、数组等)一样来处理函数。JavaScript 函数可以:

  • 存储在变量中
  • 从一个函数返回
  • 作为参数传递给另一个函数

请注意虽然可以将函数当作对象来处理,但是函数和对象之间的一个主要区别是,函数可以被调用(即使用 () 执行),而常规对象则不能。

函数可以返回函数

函数必须始终返回一个值。无论是在 return 语句中显式指定一个值(例如,返回一个字符串、布尔值、数组等),还是函数隐式地返回 undefined(例如,一个简单地将某些东西记录到控制台的函数),函数始终只会返回一个值。

既然我们知道函数是一级函数,我们可以将函数作为一个值,十分简便地从函数返回函数!返回另一个函数的函数被称为高阶函数。

function alertThenReturn() {
  alert('Message 1!');

  return function () {
    alert('Message 2!');
  };
}

如果在y浏览器中调用 alertThenReturn(),我们会先看到一条提示消息,写着 Message 1!,接着是 alertThenReturn() 函数,它会返回一个匿名函数。但是并不会看到一个 Message 2! 提示,因为并未执行内部函数中的任何代码。那么如何执行所返回的函数呢?

function alertThenReturn() {
  alert('Message 1!');

  return function () {
    alert('Message 2!');
  };
}

// 由于 `alertThenReturn()` 会返回一个_函数_,因此我们可以给这个返回值分配一个变量:
const innerFunction = alertThenReturn();

// 然后,我们可以像使用其他函数一样使用 innerFunction 变量!
innerFunction(); // 显示 'Message 2!'

// 同样,这个函数可以被立即调用,而无需存储在一个变量中。如果我们简单地向 alertThenReturn() 添加另一组圆括号,我们仍然会得到相同的结果:
alertThenReturn()(); // 显示 'Message 1!' 然后显示 'Message 2!'

请注意这个函数调用中的两对圆括号(即 ()())!第一对圆括号将执行 alertThenReturn 函数。这个调用的返回值将返回一个函数,然后再被第二对圆括号调用!

小结

在 JavaScript 语言中,函数是一级函数。这意味着,我们可以像处理 JavaScript 中的其他元素(如字符串、数组或数字)一样来处理函数。这意味着函数可以:

  • 存储在变量中。
  • 从一个函数返回。
  • 作为参数传递给另一个函数。

回调

回调函数

JavaScript 函数是一级函数所以可以像处理其他值一样来处理函数——包括将它们传递给其他函数!

接受其他函数作为参数(或返回函数)的函数被称为高阶函数

作为参数传递给另一个函数的函数被称为回调函数

回调函数非常有用,因为它们可以将调用函数委托给其他函数。它们让你可以使用组合来构建项目,从而写出更简洁、更高效的代码。

数组方法

你可能在哪里看到过回调函数?在数组方法中!函数通常会被传递到数组方法中,并在数组(即调用该方法的数组)中的元素上被调用。

让我们详细来看几个例子:

  • forEach()
  • map()
  • filter()

forEach()

数组的 forEach() 方法接受一个回调函数,并为数组中的每个元素调用该函数。换句话说,forEach() 让你可以迭代(即遍历)一个数组,类似于使用 for 循环。

array.forEach(function callback(currentValue, index, array) {
    // 函数代码写在这里
});

回调函数本身会接收参数:当前数组元素、其索引和整个数组本身。

function logIfOdd(n) {
  if (n % 2 !== 0) {
    console.log(n);
  }
}

logIfOdd(2); // (没有什么被打印出来)

logIfOdd(3); // 3

2 被传入该函数时,logIfOdd() 不会向控制台输出任何内容,因为 2 是一个偶数。但是,当 3 被传入该函数时,3 被记录到控制台,因为它是一个奇数。

logIfOdd() 函数对于单个数字非常适用,但是,如果我们想检查整个数组并仅记录奇数呢?

[1, 5, 2, 4, 6, 3].forEach(function logIfOdd(n) {
  if (n % 2 !== 0) {
    console.log(n); // 1 5 3
  }
});

上述例子中是一个有名函数,但将一个匿名函数作为参数传递给 forEach() 是很常见的,另外,也可以简单地传入函数的名称:

function logIfOdd(n) {
  if (n % 2 !== 0) {
    console.log(n); // 1 5 3
  }
}
[1, 5, 2, 4, 6, 3].forEach(logIfOdd);

以上所示的三种不同方式会产生相同的输出(即将 153 记录到控制台)。

map()

数组的 map() 方法类似于 forEach(),也会为数组中的每个元素调用一个回调函数。但是,.map() 会根据回调函数所返回的内容返回一个新的数组。

const names = ['David', 'Richard', 'Veronika'];

const nameLengths = names.map(function(name) {
  return name.length;
});
console.log(nameLengths); // [ 5, 7, 8 ]

传递给 map() 的函数会为 names 数组中的每个项目被调用!该函数会接收数组中的第一个名称,将其存储在 name 变量中,并返回其长度。然后,它会再为剩下的两个名称这样做。

请记住,.forEach().map() 之间的主要区别在于,.forEach() 不会返回任何东西,而 .map() 则会返回一个新的数组,其中包含从该函数返回的值。

所以,nameLengths 将是一个新的数组[5, 7, 8]。这一点非常重要,map() 方法会返回一个新的数组,而不会修改原始数组。

filter()

数组的 filter() 方法与 map() 方法类似

  • 它在一个数组上被调用
  • 它将一个函数作为参数
  • 它会返回一个新的数组

区别在于,传递给 filter() 的函数会被用作一个测试,只有数组中通过测试的项目会被包含在新的数组中。

const names = ['David', 'Richard', 'Veronika'];

const shortNames = names.filter(function(name) {
  return name.length < 6;
});

console.log(shortNames); // [ "David" ]

同样,与 map() 一样,传递给 filter() 的函数会为 names 数组中的每个项目被调用。第一个项目(即 'David')会被存储在 name 变量中。然后执行测试——这一步会进行过滤。首先,它会检查该名称的长度。如果它是 6 或更大,则会被跳过(而不会被包含在新数组中!)。相反,如果该名称的长度小于 6,那么 name.length < 6 则会返回 true,并且该名称会被包含在新数组中!

最后,就像 map() 一样,filter() 方法会返回一个新的数组,而不会修改原始数组。

小结

函数可以接受各种不同的参数,包括字符串、数字、数组和对象。

  • 由于函数是一级函数,因此函数可以作为参数传递给指定的函数。
  • 接受其他函数作为参数的函数被称为高阶函数。
  • 作为参数传递给其他函数的函数被称为回调函数。

你可以使用回调来传递未命名的函数(即匿名函数),从而减少浮动的变量。你还可以使用它们将调用函数委托给其他函数。诸如 forEach()map()filter()这样的数组方法利用回调对给定的数组元素执行函数。

延伸

作用域

块作用域和函数作用域决定了在某些代码中可以看到变量的地方,计算机科学家将此称为词法作用域。

但还有另一种被称为运行时作用域的作用域。当一个函数被运行时,它会创建一个新的运行时作用域。这个作用域表示该函数的上下文,具体来说,就是可供该函数使用的一组变量。

函数的作用域描述了给定函数内的可用变量。函数内的代码究竟能够访问什么呢?

  1. 该函数的参数
  2. 该函数内声明的局部变量
  3. 来自其父函数作用域的变量
  4. 全局变量

JavaScript 使用函数作用域

你可能想知道,为什么作用域与 JavaScript 中的函数如此密切相关。特别是如果你曾用过其他编程语言,这可能看起来有点不同寻常(例如,Ruby 中的块就有自己的作用域)!

这完全是因为 JavaScript 中的变量传统上是在函数作用域内定义的,而不是在块作用域内。由于输入一个函数会改变作用域,因此在函数内部定义的变量在该函数外部是不可用的。相反,如果在块中定义了任何变量(例如,在 if 语句中),则这些变量在该块外部是可用的。

var globalNumber = 5;

function globalIncrementer() {
  const localNumber = 10;

  globalNumber += 1;
  return globalNumber;
}

console.log(globalIncrementer()); // 6
console.log(globalIncrementer()); // 7
console.log(globalIncrementer()); // 8

console.log(localNumber); // ReferenceError: localNumber is not defined

在以上示例中,globalNumber 在该函数外部; 它是 globalIncrementer() 函数可以访问的一个全局变量。globalIncrementer() 只是声明了一个局部变量 (localNumber),然后把 globalNumber1,再返回 globalNumber 本身的更新值。

由于 JavaScript 使用函数作用域,因此函数可以访问它自己的所有变量以及它外部的所有全局变量。

块作用域

另外,ES6 语法允许额外的作用域,并使用 letconst 关键字来声明变量。这些关键字在 JavaScript 中用于声明块作用域变量,并在很大程度上取代了使用 var 的需求。

作用域链

每当你的代码试图在函数调用过程中访问一个变量时,JavaScript 解释器总是会先查看其本地变量。如果找不到该变量,搜索将在所谓的作用域链上继续查找。

function one() {
  two();
  function two() {
    three();
    function three() {
      // 函数 three 的代码
    }
  }
}

one();

在以上示例中,当 one() 被调用时,其他所有嵌套函数也会被调用(一直到 three())。

你可以直观地看到作用域链从最内层开始向外移动:从 three(),到 two(),到 one(),最后到 window(即全局/窗口对象)。这样,函数 three() 不仅可以访问它「上面」的变量和函数(即 two()one() 的)——three() 还可以访问在 one() 外部定义的任何全局变量。

当解析变量时,JavaScript 引擎会先查看嵌套子函数的局部定义变量。如果能够找到,则检索该值;否则,JavaScript 引擎会继续向外查找,直到变量被解析。如果 JavaScript 引擎已到达全局作用域,但仍然无法解析变量,则该变量为未定义。

Window 对象

当 JavaScript 应用程序在主机环境(例如浏览器)内运行时,主机会提供一个 window 对象,也被称为全局对象。所声明的任何全局变量都是作为这个对象的属性被访问的,它表示作用域链的最外层。

变量阴影

当你所创建的变量与作用域链中的另一个变量具有相同名称时,会发生什么?

JavaScript 不会弹出错误消息或阻止你创建这样一个变量。实际上,局部作用域的变量只会暂时「遮蔽」外部作用域中的变量。这被称为变量阴影。

const symbol = '¥';

function displayPrice(price) {
  const symbol = '$';
  console.log(symbol + price);
}

displayPrice('80'); // $80

在以上代码段中,请注意 symbol 在两个地方被声明:

  1. displayPrice() 函数外部,作为一个全局变量。
  2. displayPrice() 函数内部,作为一个局部变量。

在调用 displayPrice() 并向其传递参数 80 后,该函数会输出 $80 到控制台。

JavaScript 解释器如何知道应该使用 symbol 的哪个值呢?答案是,由于指向 '$' 的变量是在函数内部(即「内部」作用域)声明的,因此它将覆盖属于外部作用域的所有同名变量——例如指向 '¥' 的全局变量。结果,所显示的就是 '$80',而不是 '¥80'

总而言之,如果在不同上下文中的变量之间有任何命名重叠,则会通过从内部作用域到外部作用域(即从局部一直到全局)遍历作用域链来解决。因此,局部变量总是优先于更宽作用域内与其同名的变量。

小结

当一个函数被运行时,它会创建自己的作用域。函数的作用域是该函数中可用的一组变量。一个函数的作用域包括:

  1. 该函数的参数
  2. 该函数内部声明的局部变量
  3. 来自其父函数作用域的变量
  4. 全局变量

JavaScript 中的变量也遵循函数作用域。这意味着,在函数内定义的任何变量均不可用于该函数之外,尽管在块内定义的任何变量(例如 iffor)在该块之外可用的。

在访问变量时,JavaScript 引擎将遍历作用域链,首先查看最内层(例如函数的局部变量),然后查看外层作用域,最后在必要时到达全局作用域。

在本部分,我们看到了许多嵌套函数的例子,它们可以访问在其父函数作用域中(即在该函数嵌套的作用域中)声明的变量。这些函数及其声明位置的词法环境其实有一个非常特别的名称:闭包。闭包在 JavaScript 中与作用域密切相关,并可实现一些强大、实用的应用。

延伸

闭包

函数保留其作用域

我们刚刚介绍了函数作用域是如何运作的,以及作用域链是如何创建的。当使用标识符(即变量)时,作用域链将被检查,以检索该标识符的值。该标识符可能会在局部作用域中(在函数或块中)被找到。如果在局部找不到,则可能存在于外部作用域内。然后,它会继续检查下一个外部作用域,以及更外面的作用域,直到达到全局作用域(如有必要)。标识符查找和作用域链对于函数访问代码中的标识符来说是非常强大的工具。

这可以帮助你做一些非常有趣的事情:现在创建一个函数,用一些变量包装起来,然后保存起来以备后用。如果屏幕上有五个按钮,则可以编写五个不同的点击处理器函数,也可以使用相同的代码五次,每次保存不同的值。

function remember(number) {
    return function() {
        return number;
    }
}

const returnedFunction = remember(5);

console.log( returnedFunction() ); // 5

当 Javascript 引擎输入 remember 时,它会创建一个新的执行作用域,指向先前的执行作用域。这个新的作用域包含一个对 number 参数的引用(一个值为 5 的不可变 Number)。当引擎到达内部函数(一个函数表达式)时,它会将一个链接附加到当前的执行作用域。

函数保留对其作用域的访问的这个过程被称为闭包。在这个例子中,内部函数会「遮蔽」 number。闭包可以捕获它所需要的任何数量的参数和变量。MDN 将闭包定义为:

函数及其声明位置的词法环境的组合。

如果你不知道“词法环境”这个词是什么意思,你可能也不会明白这个定义。ES5 规范(英)将词法环境解释为:

标识符与特定变量和函数基于 ECMAScript 代码的词法嵌套结构的关联。

在这里,“词法环境”是指在 JavaScript 文件中编写的代码。因此,闭包就是:

  • 函数本身,以及
  • 该函数声明位置的代码(更重要的是其作用域链)

当一个函数被声明时,它会锁定在作用域链上。你可能会认为这很简单,因为我们刚在上一部分学过。但是,函数的真正有趣之处在于,即使它在声明位置以外的地方被调用时,也会保留该作用域链。这一切都是由于闭包!

让我们回头来看上面的例子——当 remember(5) 被执行和返回后,所返回的函数如何能够继续访问 number 的值(即 5)?

闭包由函数和词法环境组成,词法是指函数的作用域表示能够访问的范围,作用域和闭包之间的关系非常紧密,每个函数都有自己的闭包就像每个函数都有自己的作用域,当函数从另一个函数中返回时函数闭包就会发挥强大之处,当函数在某个函数中被声明但是返回并在声明之外运行,它将因为闭包而依然能够访问声明所在的作用域。

创建一个闭包

每次定义函数时,都会为该函数创建闭包。因此,严格来说,每个函数都有闭包!这是因为,函数至少会在作用域链上遮蔽另一个上下文:全局作用域。但是,当你使用嵌套函数(即在另一个函数中定义的函数)时,闭包的功能才会真正发挥出来。

还记得吗,嵌套函数可以访问其外部的变量。根据我们所学到的作用域链知识,这包括来自外层封闭函数本身的变量!这些嵌套函数可以捕获(或「遮蔽」)并未作为参数传入,也不在局部定义的变量,也被称为自由变量。

需要特别指出的是,函数会保持对其父作用域的引用。如果仍可访问该函数的引用,作用域就会保持不变!

闭包和作用域

const myName = 'Andrew';

function introduceMyself() {
  const you = 'student';

  function introduce() {
    console.log(`Hello, ${you}, I'm ${myName}!`);
  }

  return introduce();
}

introduceMyself(); // 'Hello, student, I'm Andrew!'

总结来说:myName 是一个在函数外部定义的变量,因此它是全局作用域内的一个全局变量。这意味着,myName 可供所有函数使用。另一方面,youintroduce() 引用,尽管它并非在 introduce() 中被声明。这是可能的,因为嵌套函数的作用域包括该函数的嵌套作用域中所声明的变量(即定义该函数的父函数作用域中的变量)。

事实证明,introduce() 函数及其词法环境形成了一个闭包。因此,introduce() 不仅可以访问全局变量 myName,还可以访问在其父函数 introduceMyself() 的作用域中声明的变量 you

闭包的应用

闭包的两个常见和强大的应用:

  1. 隐含地传递参数。
  2. 在函数声明中,存储作用域的快照。

垃圾回收

JavaScript 通过自动垃圾回收来管理内存。这意味着,当数据不再可引用时(即没有可用于可执行代码的对该数据的剩余引用),它将被「垃圾回收」,并在稍后的某个时间点被销毁。这可以释放该数据曾经消耗的资源(即计算机内存),从而使这些资源可供重新使用。

让我们结合闭包来看一下垃圾回收。我们知道,父函数的变量可以被嵌套的内层函数访问。如果嵌套函数捕获并使用其父函数的变量(或其作用域链上的变量,如其父函数的父函数的变量),那么只要使用这些变量的函数仍可被引用,这些变量就会一直保留在内存中。

因此,JavaScript 中的可引用变量不会被垃圾回收!

function myCounter() {
  let count = 0;

  return function () {
    count += 1;
    return count;
  };
}

嵌套函数的存在会使 count 变量始终不可用于垃圾回收,因此 count 仍然可供将来访问。毕竟,当一个给定函数被返回时,该函数(及其作用域)并不会结束。请记住,JavaScript 中的函数会保留对其创建位置的作用域的访问!

小结

闭包是指函数和该函数声明位置的词法环境的组合。每次定义函数时,都会为该函数创建闭包。对于在一个函数中定义另一个函数的情况,闭包尤其强大,它让嵌套函数可以访问其外部的变量。即使父函数已返回,函数也会保留一个到其父作用域的链接。这可以防止父函数内的数据被垃圾回收。

延伸

立即调用函数表达式

函数声明与函数表达式

在详细了解立即调用函数表达式 (IIFE) 之前,先确保你已经知道了函数声明与函数表达式之间的区别。

函数声明会定义一个函数,而不需要将变量赋给函数。它只是声明一个函数,而不会返回一个值。

函数表达式会返回一个值。函数表达式可以是匿名或命名的,并且是另一个表达式语法的一部分。它们通常也会赋给变量。

// 函数声明
function returnHello() {
  return 'Hello!';
}
// 匿名函数表达式
const myFunction = function () {
  return 'Hello!';
};
// 命名函数表达式
const otherFunction = function returnHello() {
  return 'Hello!';
};

立即调用函数表达式:结构和语法

立即调用函数表达式或 IIFE(发音为 iffy)是在定义之后立即被调用的函数。

(function sayHi(){
    alert('Hi there!');
  }
)(); // 展示 'Hi there!'

这个语法看起来可能有点奇怪,但我们所做的只是将一个函数包在圆括号中,然后在末尾添加一对圆括号来调用它!

向 IIFE 传递参数

(function (name){
    alert('Hi, ' + name);
  }
)('Andrew'); // 展示 'Hi, Andrew'

第二对圆括号不仅可以立即执行前面的函数,还可以放置该函数可能需要的任何参数!

IIFE 和私有作用域

IIFE 的主要用途之一就是创建私有作用域。JavaScript 中的变量传统上遵循函数作用域。有鉴于此,我们可以利用闭包的行为方式来保护变量或方法不被访问!

const myFunction = (
  function () {
    const hi = 'Hi!';
    return function () {
      console.log(hi);
    }
  }
)();

立即调用函数表达式被用于立即运行一个函数。这个函数会运行并返回一个存储在 myFunction 变量中的匿名函数。

请注意,所返回的函数会遮蔽(即捕获hi 变量。这使得 myFunction 可以保持一个无法在函数外部访问的私有可变状态!更重要的是:由于所表达的函数被立即调用,因此 IIFE 可以很好地包裹代码,以免污染全局作用域。

如果这听起来很熟悉,那是因为 IIFE 与你所学到的有关作用域和闭包的知识密切相关!

IIFE、私有作用域和事件处理

让我们再来看一个立即调用函数表达式的例子,这次是结合事件处理来看。假设我们想在页面上创建一个按钮,每隔一次点击就提醒用户。这样做的第一步思路可以是跟踪按钮被点击的次数。但是,我们应该如何保持这个数据呢?

我们可以使用在全局作用域内声明的一个变量来跟踪计数(如果应用程序的其他部分需要访问计数数据,这样做就很合理)。但是,更好的方式是将这些数据放在事件处理器中!首先,它可以防止我们使用额外的变量来污染全局(还可能会发生名称冲突)。更重要的是:如果我们使用 IIFE,我们就可以利用闭包来保护 count 变量不被外部访问!这可以防止意外的改变或未预期的连带结果无意中改变计数。

首先,让我们先来创建一个包含单个按钮的 HTML 文件:

<!-- button.html -->
<html>
  <body>
     <button id='button'>Click me!</button>
     <script src='button.js'></script>
  </body>
</html>

这里没有什么新鲜东西,就是一个 ID 为 'button'<button> 标签。我们还引用了一个现在要来构建的 button.js 文件。在该文件中,让我们通过它的 ID 来检索对该元素的引用,然后将该引用保存到变量 button 中:

// button.js
const button = document.getElementById('button');

接下来,我们将添加一个事件监听器到 button,并监听一个 ‘click’ 事件。然后,我们将传入一个 IIFE 作为第二个参数:

// button.js
button.addEventListener('click', (function() {
  let count = 0;

  return function() {
    count += 1;

    if (count === 2) {
      alert('This alert appears every other press!');
      count = 0;
    }
  };
})());

在 IIFE 中的过程有点复杂,让我们来仔细分析一下!

首先,我们声明了一个局部变量 count,它最初被设置为 0。然后,我们从函数返回一个函数。所返回的函数会递增 count,但当计数达到 2 时,则会提醒用户,并将计数重置为 0

需要特别指出的是,所返回的函数会遮蔽 count 变量。也就是说,由于函数会维持对其父函数作用域的引用,count 可供所返回的函数使用!因此,我们立即调用返回该函数的函数。而且,由于所返回的函数可以访问内部变量 count,所以创建了一个私有作用域,以便有效地保护该数据!我们将 count 放在一个闭包中,从而让我们可以保留每次点击的数据。

立即调用函数表达式的好处

我们已经知道,使用立即调用函数表达式可以创建一个私有作用域来保护变量或方法不被访问。IIFE 最终会使用所返回的函数来访问闭包内的私有数据。这样做很有好处:虽然这些所返回的函数可以公开访问,但它们仍可保持内部定义变量的私有性!

另一个使用 IFFE 的好机会是当你想要执行一些代码,而又不想创建额外的全局变量时。但要注意的是,IIFE 只应该被调用一次,以创建唯一的执行上下文。如果你有一些代码需要重用(例如,一个要在应用程序中多次执行的函数),那么声明该函数然后再调用它可能是更好的选择。

总而言之,如果你只想完成某个一次性任务(例如初始化应用程序),那么 IIFE 将是完成任务,同时避免额外变量污染全局环境的好办法。毕竟,清理全局名称空间可以减少重复变量名称冲突的几率。

小结

立即调用函数表达式 (IIFE) 是在定义之后立即被调用的函数。将 IIFE 和闭包结合使用可以创建一个私有作用域,从而维护内部定义变量的私有性。而且,由于所创建的变量较少,IIFE 将有助于最大限度地减少对全局环境的污染,从而降低变量名称冲突的几率。

您可能还喜欢...

发表评论

电子邮件地址不会被公开。 必填项已用*标注