函数和作用域


概述

在数学上,我们会遇到各式各样的函数,比如经典的二次函数,它常常被写成 f(x) = x2,当我们代入不同的 x 值,函数的值也会跟随变化。

在程序中,也有函数。程序中的函数可以完成比数学函数更复杂的功能,它是一系列程序语句的组合。

在 JS 中,函数被按照下列方式声明一个二次函数:

function f(x) {
  var y = x * x;
  return y;
}

其中:

  • function:是 函数声明 的关键字,类似变量声明的 var 关键字;
  • f:是 函数名,由标识符组成,和变量名类似;
  • (x):是函数的输入部分,x 被称作函数的 参数,更细致的说,它读作 形参,它用来让函数接收外部的数据;
  • {...}:花括号是函数声明的一部分,花括号内部是函数的实现代码;
  • reutrn:是函数的输出部分,描述了函数的 返回值,它用来从返回函数内的数据并停止后续代码的执行;

现在,让我们来执行以下这个函数:

var re = f(9);
console.log(re); // 81

执行函数的时候,我们直接使用函数名,并在函数的后面添加圆括号表示执行,圆括号内写入的数据也称作 实参

我们一直用来打印数据的 console.log 也是一个函数,它由 JS 内部引擎实现,用来打印数据和调试。

函数就像一个独立的程序,它拥有自己的输入输出和内部逻辑。

备注:在 JS 中,函数属于引用类型的数据,它被用来存储 JS 代码。

函数声明与函数表达式

函数声明

现在我们来反推一下函数的声明。

一个最简单的函数声明如下:

function foo(/* 参数部分 */) {
  /*主体代码*/
}
function bar() {}

上面的函数没有任何功能,只是一个空壳,但是它们是 可以执行的,只是没有任何逻辑的处理。

函数是使用 function 关键字来声明的,函数名和变量名的声明规则相同,一般使用大小写字母数字下划线 _ 或者美元符 $ 组成。比如:

function _() {}
function $() {}
function $foo() {}
function _foo() {}

上面四个函数的声明都是有效的。

函数名后紧接函数的参数,参数部分使用一个圆括号包裹,主体代码使用花括号包裹,这两个括号都不可省略,比如:

function foo;
function bar ();
function baz {};

上面三种方式都是错误的,圆括号和花括号不可省略。

重复声明

查看一下代码:

function foo() {
  console.log("one");
}

function foo() {
  console.log("tow");
}

foo();

上述代码会打印 tow,和变量的重复声明不一样:

  • 变量重复声明无效。
  • 函数的重复声明会覆盖之前的声明。

函数执行

编写一个简单的函数并执行:

function foo() {
  console.log("running!");
}
foo(); // "running!"

上述代码中,最后一行的 foo 标识符是一个 函数表达式,函数表达式可以被执行。我们可以在函数表达式末尾加圆括号执行一个函数表达式,被执行的函数表达式会执行其内部代码。

参数

可以使用函数参数向函数内部传递数据,比如:

function foo(bar) {
  console.log("bar :", bar);
}

foo("ohh"); // "bar :" "ohh"

上面代码中:

  • 字符串 ohh 被称作 实参,标识符 bar 被称作 形参
  • foo 函数把字符串 ohh 赋值给了它的形参 bar,形参在函数内部等同于一个变量;
  • 接着 foo 的内部程序开始执行;

一个函数可以传递多个参数:

function foo(x, y) {
  console.log(x + y);
}

foo(1, 2); // 3
foo(10, 20); // 30

函数的参数传递是有顺序的,需要按顺序传递。

函数表达式

函数也是数据,它就是之前我们介绍的引用类型的其中一种,既然他是数据,那么自然就可以让它赋值给变量:

var foo = function () {
  console.log("foo run");
};
foo();

上面代码中,赋值给 foo 变量的函数是一个 函数表达式。函数表达式的函数名是可以省略的,这种省略函数名的函数,被叫做匿名函数。

函数表达式的函数名也可以不省略,比如:

var foo = function bar() {
  console.log("inner:", foo, bar);
};
foo();
console.log("outer:", foo, bar);

函数表达式的函数名只能在函数内部使用,上述代码中,bar 只能在函数内部访问,外部访问会报错。

如何区分函数声明和函数表达式?

  • 函数声明的 function 关键字在语句的开头,前面没有任何逻辑。
  • 函数表达式是可以执行的,函数声明不行。

比如:

function foo() {alert("run");}();

上面的代码会报错,因为函数声明不可执行,但是函数表达式可以:

var foo = function () {alert('run')}();

函数在声明后,再次使用函数名就可以得到对应函数的表达式,所以函数执行时,执行的是函数表达式:

function foo() {
  alert(1);
}
// 此时使用的 foo 也是一个函数表达式
foo();

作用域

思考一下代码会发生什么:

var a = 1;
function foo() {
  var a;
  console.log(a);
}

foo();

上述代码会打印出 undefined,如果按照变量重复声明无效的原则,第二个 var a 无效,上面的代码的运行结果和此原则产生了冲突。

这里你需要理解一个新的概念,作用域。不论变量还是函数声明,它们都有自己可以被使用的区域,这个区域 就是就是变量自身所在的作用域。

因为函数类似一个小型的程序,它有自己的输入输出和逻辑,所以 foo 函数内部形成了一个新的作用域。

因为 foo 内部的变量 a 是在函数内部的作用域声明的,两个变量 a 并不在同一个作用域内,当我们在函数内部访问变量 a 的时候,它会优先寻找当前作用域内的变量 a,于是打印一个没有初始化的变量 a

查看下列代码:

var a = 1;
function foo() {
  console.log(a);
}
foo();

上述代码会打印出 1,此时 console.log 去当前作用域寻找变量 a 的时候是找不到的,那么程序就会向外层的作用域继续寻找,因为外层作用域存在变量 a,所以最后打印出 a 的值。

继续深入,查看下列代码:

var a = 1;
function foo() {
  var a = 2;

  function bar() {
    console.log(a);
  }
  bar();
}

foo();

上述代码中,我们在 foo 函数内部又声明了一个函数 bar,并且执行了它。bar 内部依旧是打印变量 a,此时形成了 3 个作用域,foo 外部作用域,foo 内部作用域和 bar 内部作用域。

当程序打印变量 a 的时候,它会执行以下操作:

  1. bar 内部作用域寻找变量 a,找不到,去外层寻找
  2. foo 内部作用域寻找变量 a,找到了,它的值是 2,打印 2

作用域术语

上面一节中,程序在寻找变量时,是一层一层作用域去寻找的,这种作用域之间的嵌套,被叫做 作用域链

程序末端的作用域,被叫做 全局作用域,也就是最外层的作用域。

作用域链的单向访问

作用域链中,外层的作用域范围内不可访问内层作用域的数据,比如:

function foo() {
  var a = 1;
}
foo();
console.log(a); // error

当我们打印变量 a 的时候,只会在当前作用域以及上层作用域寻找变量 a,不会向某个函数内部寻找。

内存清理

函数执行后,一般情况下,都不会保存执行时产生的数据,每次重复执行函数,都是一次新的执行,比如:

function foo() {
  var a = 0;
  console.log(a);
  a++;
}
foo(); // 0
foo(); // 0

函数并不能获取上一次声明了变量,每次执行结束后,内存都会被清理,并不会保留上一次运行产生的数据。

全局作用域的数据比较特殊,全局作用域的数据不会被清理,程序不会因为运行结束,就清理所有全局的数据或函数,除非关闭页面。

变量的隐式声明与作用域

之前说过,变量可以不声明直接做赋值操作,程序会自动隐式声明变量。这在全局作用域上,并没有任何不妥,但是如果在函数的作用域内,就会发生异样:

function foo() {
  a = 1;
}

foo();
console.log(a);

上述代码会打印出 1,变量被隐式声明到了全局作用域。

这是因为当在函数内部隐式声明一个变量的时候,程序会按照作用域内去寻找变量,如果找不到,向上层作用域寻找,如果一直到全局作用域都找不到,那么就把变量隐式声明到全局作用域上。

除非你非常清楚的知道,你在隐式声明一个全局变量,否则不要那么做,这样很可能增加程序的调试和维护难度。

函数没有隐式声明,所以不会出现函数隐式声明到到全局作用域上。

函数参数

实参与形参

函数的参数被区分为 实参形参,实参是真实传入的实际参数,形参是帮助描述函数声明的形式参数。

例:

function foo(a, b) {
  console.log(a);
  console.log(b);
}

foo(1, 2);

上述代码中,ab 是形参,函数内部都使用形参来编写逻辑,字面量 12 是实参,它是函数执行时实际运行的参数。

实参的值传递给形参的过程叫做 参数传递,它类似于赋值操作。上述代码中,实参 1 就赋值给了形参 a,实参 2 也赋值给了形参 b

实参也不一定是字面量,可以把变量当做实参:

var c = 1,
  d = 2;
foo(c, d);

形参的作用域是在函数内部,因为作用域的存在,实参和形参可以同名而不产生冲突:

function foo(a, b) {
  console.log(a);
  console.log(b);
}

var a = 1,
  b = 2;
foo(a, b);

上述代码中,foo 声明外部的 ab 标识符是全局变量,最后一行代码 foo(a, b) 中,ab 被当做实参传入函数内部。

函数内部的标识符 ab 是形参,它们的作用域在函数内部,所以函数打印参数 ab 的时候,会打印参数,而不是全局变量。

如果很难理解,就修改外部变量名,防止混淆。

参数无限制

JS 中的函数参数的限制完全取决于开发者本身,一个函数的实参数量可以不等同于形参数量,比如:

function foo(a, b) {
  console.log(a);
  console.log(b);
}

foo();
foo(1);
foo(1, 2);
foo(1, 2, 3);

上述执行 foo 函数的方式都不会报错,形参的默认值是 undefined,所以上述代码中,前两次执行时,没有传入的参数会打印出 undefined

多余传入的参数并不会被丢弃,可以通过其他关键字获得,如:

function foo(a, b) {
  console.log(arguments);
}

foo(1, 2, 3);

其中,arguments 是一个特殊的关键字,它可以获取到整个参数列表的值,它是一个引用类型,在介绍完引用类型后,我会详细介绍它。

返回值

每个函数至多可以拥有一个返回值,它会作为执行函数的结果,返回值使用 return 关键字描述:

function foo() {
  return 1;
}

var a = foo();
console.log(a);

上述代码中,foo 函数的返回值是数字 1

计算值 空值 不返回值

函数的返回值也可是一个计算值:

function foo(n) {
  return n * 10;
}

console.log(foo(5)); // 50

还可以返回一个空值或者不返回值:

function foo() {
  return;
}
function bar() {}

console.log(foo(), bar()); // undefined undefined

上述代码中,空值返回和不返回值,得到的结果都是 undefined

结束运行

在函数中,当执行到 return 关键字后,函数会结束 return 后的语句运行:

function foo() {
  console.log("start");
  return;
  console.log("end");
}
foo(); // start

上述代码中,不会打印 end 字符串,函数会在执行完 return 语句后停止执行。