函数——可重用的代码块

编程中另一个基本概念是函数,它允许你将完成单一任务的代码存储在一个定义好的块中,然后通过一个简短的命令在需要时调用该代码,而不是重复多次键入相同的代码。在本文中,我们将探讨函数背后的基本概念,例如基本语法、如何调用和定义它们、作用域和参数。

预备知识 了解 HTMLCSS 基础,熟悉前面课程中介绍的 JavaScript 基础。
学习成果
  • 函数旨在创建可重用的代码块,以便在需要时进行调用。
  • 函数在 JavaScript 中随处可见,其中一些是内置于浏览器中的,另一些是用户定义的。
  • 函数和方法之间的区别。
  • 调用函数。
  • 匿名函数和箭头函数。
  • 定义函数参数,向函数调用传递实参。
  • 全局作用域和函数/块级作用域。
  • 理解什么是回调函数。

在哪里可以找到函数?

在 JavaScript 中,函数无处不在。事实上,到目前为止,我们一直在整个课程中使用函数;我们只是没有过多地谈论它们。然而,现在是时候我们开始明确地谈论函数,并真正探索它们的语法了。

几乎任何时候你使用一个带有括号 — () — 的 JavaScript 结构,并且你没有使用像 for 循环while 或 do...while 循环if...else 语句这样的常见内置语言结构时,你都在使用一个函数。

内置浏览器函数

在本课程中,我们大量使用了内置于浏览器中的函数。

例如,每次我们操作文本字符串时

js
const myText = "I am a string";
const newString = myText.replace("string", "sausage");
console.log(newString);
// the replace() string function takes a source string,
// and a target string and replaces the source string,
// with the target string, and returns the newly formed string

或者每次我们操作数组时

js
const myArray = ["I", "love", "chocolate", "frogs"];
const madeAString = myArray.join(" ");
console.log(madeAString);
// the join() function takes an array, joins
// all the array items together into a single
// string, and returns this new string

或者每次我们生成随机数时

js
const myNumber = Math.random();
// the random() function generates a random number between
// 0 and up to but not including 1, and returns that number

我们都在使用一个函数

注意:如果需要,可以随时将这些行输入到浏览器的 JavaScript 控制台中,以重新熟悉它们的功能。

JavaScript 语言有许多内置函数,可以让你在不必自己编写所有代码的情况下完成有用的事情。事实上,当你调用(一个花哨的词,意思是运行或执行)一个内置浏览器函数时,你正在调用的某些代码是无法用 JavaScript 编写的——其中许多函数正在调用后台浏览器代码的部分,这些代码主要用 C++ 等低级系统语言编写,而不是 JavaScript 等 Web 语言。

请记住,某些内置浏览器函数不属于核心 JavaScript 语言——有些是作为浏览器 API 的一部分定义的,这些 API 在默认语言的基础上提供更多功能(有关更多描述,请参阅我们课程的这个早期部分)。我们将在后面的模块中更详细地探讨浏览器 API 的使用。

函数与方法

作为对象一部分的函数称为方法;你将在模块后面学习对象。目前,我们只是想澄清关于方法与函数可能存在的任何混淆——你很可能会在 Web 上查看相关资源时遇到这两个术语。

到目前为止我们使用的内置代码有两种形式:函数方法。你可以在我们的 JavaScript 参考中查看内置函数的完整列表,以及内置对象及其对应的方法。

到目前为止,你也在课程中看到了许多自定义函数——在你的代码中定义,而不是在浏览器中定义的函数。每当你看到一个自定义名称后面直接跟着括号时,你都在使用一个自定义函数。在我们的random-canvas-circles.html示例中(另请参阅我们循环文章的完整源代码),我们包含了自定义的draw()函数,它看起来像这样:

js
function draw() {
  ctx.clearRect(0, 0, WIDTH, HEIGHT);
  for (let i = 0; i < 100; i++) {
    ctx.beginPath();
    ctx.fillStyle = "rgb(255 0 0 / 50%)";
    ctx.arc(random(WIDTH), random(HEIGHT), random(50), 0, 2 * Math.PI);
    ctx.fill();
  }
}

这个函数在<canvas>元素中绘制 100 个随机圆。每次我们想这样做时,我们只需用这个来调用函数:

js
draw();

而不是每次想重复时都重新编写所有代码。函数可以包含你喜欢的任何代码——你甚至可以在函数内部调用其他函数。例如,上面的函数调用了三次random()函数,该函数由以下代码定义:

js
function random(number) {
  return Math.floor(Math.random() * number);
}

我们需要这个函数,因为浏览器的内置 Math.random() 函数只生成 0 到 1 之间的随机小数。我们需要一个 0 到指定数字之间的随机整数。

调用函数

你现在可能已经很清楚这一点了,但以防万一,要真正使用一个函数,在定义它之后,你必须运行或调用它。这是通过在代码中某个地方包含函数名称,然后是括号来完成的。

js
function myFunction() {
  alert("hello");
}

myFunction();
// calls the function once

注意:这种创建函数的形式也称为函数声明。它总是会被提升,因此你可以在函数定义上方调用函数,并且它会正常工作。

函数参数

有些函数在调用时需要指定参数——这些是需要在函数括号内包含的值,函数需要它们才能正常工作。

注意:参数有时被称为实参、属性或甚至特性。

例如,浏览器内置的 Math.random() 函数不需要任何参数。当被调用时,它总是返回 0 到 1 之间的随机数:

js
const myNumber = Math.random();

然而,浏览器内置的字符串 replace() 函数需要两个参数——在主字符串中查找的子字符串,以及用于替换该字符串的子字符串:

js
const myText = "I am a string";
const newString = myText.replace("string", "sausage");

注意:当你需要指定多个参数时,它们用逗号分隔。

可选参数

有时参数是可选的——你不需要指定它们。如果你不指定,函数通常会采用某种默认行为。例如,数组 join() 函数的参数是可选的:

js
const myArray = ["I", "love", "chocolate", "frogs"];
const madeAString = myArray.join(" ");
console.log(madeAString);
// returns 'I love chocolate frogs'

const madeAnotherString = myArray.join();
console.log(madeAnotherString);
// returns 'I,love,chocolate,frogs'

如果没有包含参数来指定连接/分隔字符,则默认使用逗号。

默认参数

如果你正在编写一个函数并希望支持可选参数,你可以通过在参数名称后添加=,然后是默认值来指定默认值:

js
function hello(name = "Chris") {
  console.log(`Hello ${name}!`);
}

hello("Ari"); // Hello Ari!
hello(); // Hello Chris!

匿名函数和箭头函数

到目前为止,我们只创建了这样一个函数:

js
function myFunction() {
  alert("hello");
}

但是你也可以创建一个没有名称的函数:

js
(function () {
  alert("hello");
});

这被称为匿名函数,因为它没有名称。当函数期望接收另一个函数作为参数时,你经常会看到匿名函数。在这种情况下,函数参数通常作为匿名函数传递。

注意:这种创建函数的形式也称为函数表达式。与函数声明不同,函数表达式不会被提升。

匿名函数示例

例如,假设你想在用户在文本框中输入时运行一些代码。为此,你可以调用文本框的 addEventListener() 函数。这个函数期望你传递给它(至少)两个参数:

  • 要监听的事件名称,在本例中是 keydown
  • 事件发生时要运行的函数。

当用户按下某个键时,浏览器将调用你提供的函数,并向其传递一个包含此事件信息的参数,其中包括用户按下的特定键:

js
function logKey(event) {
  console.log(`You pressed "${event.key}".`);
}

textBox.addEventListener("keydown", logKey);

你可以将匿名函数传递给 addEventListener(),而不是定义一个单独的 logKey() 函数:

js
textBox.addEventListener("keydown", function (event) {
  console.log(`You pressed "${event.key}".`);
});

箭头函数

如果你像这样传递一个匿名函数,还有另一种你可以使用的形式,称为箭头函数。你将function(event)写成(event) =>

js
textBox.addEventListener("keydown", (event) => {
  console.log(`You pressed "${event.key}".`);
});

如果函数只接受一个参数,你可以省略参数周围的括号:

js
textBox.addEventListener("keydown", event => {
  console.log(`You pressed "${event.key}".`);
});

最后,如果你的函数只包含一个 return 语句,你也可以省略大括号和 return 关键字,并隐式返回表达式。在下面的示例中,我们使用 Arraymap() 方法将原始数组中的每个值翻倍:

js
const originals = [1, 2, 3];

const doubled = originals.map(item => item * 2);

console.log(doubled); // [2, 4, 6]

map() 方法依次获取数组中的每个项,并将其传递给给定的函数。然后它获取该函数返回的值并将其添加到新数组中。

因此,在上面的例子中,item => item * 2 等同于以下箭头函数:

js
function doubleItem(item) {
  return item * 2;
}

你可以使用相同的简洁语法重写 addEventListener 示例。

js
textBox.addEventListener("keydown", (event) =>
  console.log(`You pressed "${event.key}".`),
);

在这种情况下,console.log() 的值(即 undefined)会从回调函数中隐式返回。

我们建议你使用箭头函数,因为它们可以使你的代码更短、更具可读性。要了解更多信息,请参阅 JavaScript 指南中关于箭头函数的部分,以及我们的 箭头函数参考页面

注意:箭头函数和普通函数之间存在一些细微差别。它们超出了本入门教程的范围,并且在我们此处讨论的情况下不太可能产生差异。要了解更多信息,请参阅箭头函数参考文档

箭头函数在线示例

这是我们上面讨论的“keydown”示例的完整工作示例:

HTML

html
<input id="textBox" type="text" />
<div id="output"></div>

JavaScript

js
const textBox = document.querySelector("#textBox");
const output = document.querySelector("#output");

textBox.addEventListener("keydown", (event) => {
  output.textContent = `You pressed "${event.key}".`;
});

结果 - 尝试在文本框中输入并查看输出

函数作用域和冲突

让我们谈谈作用域——这是一个处理函数时非常重要的概念。当你创建一个函数时,函数内部定义的变量和其他内容都处于它们自己独立的作用域中,这意味着它们被锁定在自己独立的隔间中,外部代码无法访问。

所有函数之外的顶层称为全局作用域。在全局作用域中定义的值可以在代码的任何地方访问。

JavaScript 这样设置有各种原因——但主要是出于安全和组织考虑。有时你不希望变量在代码的任何地方都可以访问。你从其他地方调用的外部脚本可能会开始干扰你的代码并导致问题,因为它们恰好使用了与代码其他部分相同的变量名,从而导致冲突。这可能是恶意行为,也可能只是意外。

例如,假设你有一个引用两个外部 JavaScript 文件的 HTML 文件,并且它们都定义了一个使用相同名称的变量和函数:

html
<!-- Excerpt from the HTML -->
<script src="first.js"></script>
<script src="second.js"></script>
<script>
  greeting();
</script>
js
// first.js
const name = "Chris";
function greeting() {
  alert(`Hello ${name}: welcome to our company.`);
}
js
// second.js
const name = "Zaptec";
function greeting() {
  alert(`Our company is called ${name}.`);
}

你可以在 GitHub 上查看这个实时运行的示例(另请参阅源代码)。在阅读下面的解释之前,请将其加载到单独的浏览器选项卡中。

  • 当示例在浏览器中渲染时,你将首先看到一个警报框显示 Hello Chris: welcome to our company.,这意味着第一个脚本文件中定义的 greeting() 函数已被内部脚本中的 greeting() 调用所调用。

  • 然而,第二个脚本根本没有加载和运行,并且控制台打印出错误:Uncaught SyntaxError: Identifier 'name' has already been declared。这是因为 name 常量已经在 first.js 中声明,并且你不能在同一作用域中两次声明相同的常量。由于第二个脚本没有加载,因此无法调用 second.js 中的 greeting() 函数。

  • 如果我们从 second.js 中删除 const name = "Zaptec"; 这一行并重新加载页面,两个脚本都会执行。此时警报框会显示 Our company is called Chris. 函数可以被重新声明,并且源代码顺序中最后的声明将被使用。之前的声明实际上被覆盖了。

将代码的各个部分锁定在函数中可以避免此类问题,这被认为是最佳实践。

这有点像动物园。狮子、斑马、老虎和企鹅都关在自己的围栏里,只能接触到里面的东西——就像函数作用域一样。如果它们能够进入其他围栏,就会出现问题。最好的情况是,不同的动物会在不熟悉的环境中感到非常不舒服——狮子或老虎会在企鹅冰冷的水域中感到非常糟糕。最坏的情况是,狮子和老虎可能会试图吃掉企鹅!

Four different animals enclosed in their respective habitat in a Zoo

动物园管理员就像全局作用域——他们拥有进入每个围栏、补充食物、照顾生病动物等的钥匙。

玩转作用域

让我们来看一个真实的例子来演示作用域。

  1. 首先,在本地复制我们的 function-scope.html 示例。它包含两个名为 a()b() 的函数,以及三个变量——xyz——其中两个在函数内部定义,一个在全局作用域中。它还包含第三个名为 output() 的函数,该函数接受一个参数并在页面上的段落中输出它。

  2. 在浏览器和文本编辑器中打开示例。

  3. 打开浏览器开发者工具中的 JavaScript 控制台。在 JavaScript 控制台中,输入以下命令:

    js
    output(x);
    

    你应该会在浏览器视口中看到变量 x 的值。

  4. 现在尝试在控制台中输入以下内容:

    js
    output(y);
    output(z);
    

    这两个都应该在控制台中抛出类似于“ReferenceError: y is not defined”的错误。这是为什么呢?因为函数作用域:yz 被锁定在 a()b() 函数内部,所以当从全局作用域调用 output() 时,它无法访问它们。

  5. 但是,当它从另一个函数内部调用时会怎样?尝试编辑 a()b(),使它们看起来像这样:

    js
    function a() {
      const y = 2;
      output(y);
    }
    
    function b() {
      const z = 3;
      output(z);
    }
    

    保存代码并在浏览器中重新加载,然后尝试从 JavaScript 控制台调用 a()b() 函数:

    js
    a();
    b();
    

    你应该在浏览器视口中看到 yz 的值。这工作正常,因为 output() 函数是在其他函数内部调用的,与它正在打印的变量在同一个作用域中定义。output() 本身可以在任何地方使用,因为它是在全局作用域中定义的。

  6. 现在尝试像这样更新你的代码:

    js
    function a() {
      const y = 2;
      output(x);
    }
    
    function b() {
      const z = 3;
      output(x);
    }
    
  7. 再次保存并重新加载,然后在 JavaScript 控制台中再次尝试此操作:

    js
    a();
    b();
    

    a()b() 调用都应该将 x 的值打印到浏览器视口。这些工作正常,因为尽管 output() 调用与 x 定义的作用域不同,但 x 是一个全局变量,因此在所有代码中都可用。

  8. 最后,尝试像这样更新你的代码:

    js
    function a() {
      const y = 2;
      output(z);
    }
    
    function b() {
      const z = 3;
      output(y);
    }
    
  9. 再次保存并重新加载,然后在 JavaScript 控制台中再次尝试此操作:

    js
    a();
    b();
    

    这次,a()b() 调用将在控制台中抛出烦人的 ReferenceError: 变量名未定义 错误——这是因为 output() 调用和它们试图打印的变量不在同一个函数作用域中——这些变量对这些函数调用实际上是不可见的。

注意:相同的作用域规则不适用于循环(例如,for() { })和条件块(例如,if () { })——它们看起来非常相似,但它们不是一回事!请注意不要将这些混淆。

注意:ReferenceError: "x" is not defined 错误是你将遇到的最常见的错误之一。如果你收到此错误并且你确定你已定义了该变量,请检查它位于哪个作用域中。

总结

本文探讨了函数背后的基本概念,为下一篇文章铺平了道路,我们将在其中进行实践,并引导你完成构建自己的自定义函数的步骤。

另见