函数深入


return

reutrn 关键字用来在函数内部返回一个值作为函数的输出,函数会在执行完 return 语句后结束函数运行。return 语句可以返回任何表达式,比如一个三目表达式:

function foo(num) {
  return num % 2 ? "奇数" : "偶数";
}

任何数据都可以作为函数的返回值,比如数组、对象或是另一个函数:

function foo() {
  return function () {
    console.log("返回一个函数");
  };
}

function bar() {
  return {
    name: "一个对象",
  };
}

function baz() {
  return [
    ["多", "维"],
    ["数", "组"],
  ];
}

foo()(); // 返回值是一个函数表达式,那么可以再次执行
console.log(bar());
console.log(baz());

空返回

当一个函数没有返回值的时候,被称作 空返回,空返回会默认返回 undefined,空返回有以下两种方式:

一是不使用 return 关键字:

function foo(x) {
  console.log(x * x);
}
console.log(foo()); // undefined

二是使用 return 关键字,但是不返回值:

function foo (x) {
  console.log(x * x);
  return;

终止执行

使用 return 关键字会停止程序后续执行,很多时候,使用 return 只是为了控制函数不继续执行,比如:

function foo(x) {
  if (typeof x !== "number") {
    console.log("这不是一个数字");
    return;
  }
  return x * 2;
}

上面代码中,我们对参数进行类型判断,如果不是数字,那么就停止后续执行。

终止执行有一个隐性的坑,查看下列代码:

function foo() {
  return {
    name: "foo",
    other: "something",
  };
}

有写人有强迫症,就是喜欢把花括号对齐,比如:

function foo()
{
  return
  {
    name: "foo",
    other: "something",
  };
}

咋一看好像没什么问题,因为 JS 留白对代码影响不大。但是函数无法正确返回数据,这是因为 当语句的结尾没有分号时,JS 引擎自动添加分号,让语句结束,这种识别有一定规则,在 return 后面直接换行,会被视作 return 语句的结束,所以上面的代码没有返回值。程序甚至还会出现语法错误,无法解析 return 后的对象字面量。如果是数组同理:

参数默认值

函数参数可以设置默认值,但是这是新标准的规定,老旧浏览器不支持。新标准 JS 中的参数默认值按照一下方式设置:

function foo(x = 0) {
  return x * x;
}
foo(); // 0
foo(5); // 25

默认值的语法如上,在不传递值,或者传入 undefined 作为参数时,函数会使用默认值代替传入的参数,减少程序的错误。

因为这语法是新标准,很多老旧的浏览器出现时它都还没有,所以无法在老旧的浏览器中使用,比如 IE 就不能用。

不过我们可以使用短路运算来模拟函数的默认值:

function foo(x) {
  x = x || 10;
  return x * x;
}

JS 新标准中提供了很多新的语法,默认值只是冰山一角,但是新标准的语法不在本系列课程讨论范围,会在其他系列课程中讲解。

arguments

在函数内部,可以使用 arguments 关键字来访问所有参数,他们会被放到一个类似数组的对象中,arguments 不能使用数组的内置函数,仅能使用 length 属性。

function foo() {
  console.log(arguments[0]);
  console.log(arguments[1]);
}
foo("a", "b"); // 'a' 'b'

当你的函数需要无限参数的时候,你需要用到它:

function sum() {
  var re = 0;
  for (var i = 0; i < arguments.length; i++) {
    re += arguments[i];
  }
  return re;
}

console.log(sum(1, 2, 3, 4)); // 10

获取参数个数与函数名

每个函数有一个内置属性 length,它记录了当前函数的形参个数:

function foo(a, b, c) {}
console.log(foo.length); // 3

普通开发很少会使用到这个属性,在编写库或搭建代码架构时可能会用到。

每个函数都由一个内置属性 name,它记录了当前函数的名称:

var foo = function () {};
var bar = function barName() {};
function baz() {}
console.log(foo.name, bar.name, baz.name); // "foo" "barName"  "baz"

这个属性也很少会使用。

逻辑分支与函数

条件声明 bug

在极少数情况下,需要使用逻辑分支来声明不同的函数,比如:

var foo = true;

if (foo) {
  function bar() {
    console.log("bar 1");
  }
} else {
  function bar() {
    console.log("bar 2");
  }
}

bar();

上述代码并不是所有浏览器都兼容,现代浏览器大部分会打印出 'bar 1',但是 IE11 以下的版本都会打印出 'bar 2'

如果真的需要条件声明,应该使用函数表达式:

var foo = true;
var bar;

if (foo) {
  bar = function () {
    console.log("bar 1");
  };
} else {
  bar = function () {
    console.log("bar 2");
  };
}

bar();

上面的代码可以消除函数条件声明产生的 bug。

立即执行表达式

在 JS 开发,可能会常常为了封装代码,编写一个函数,然后立即把函数执行后,该函数就被废弃,再也不会重复使用,类似下面的样子:

var bar = 10;
function foo(r) {
  var PI = 3.14159265;
  console.log(r * r * PI);
}
foo(bar);

这种写法看似多绕了一圈,实际上它帮助我们隐藏了一部分代码的实现,比如上面的 PI 变量,我们就无法在函数外访问。

在 JS 中,提供一个语法糖来简化上面的代码:

var bar = 10;

(function foo(r) {
  var PI = 3.14159265;
  console.log(r * r * PI);
})(bar);

我们可以 使用一个圆括号来包裹一个函数声明,此时这个函数声明会变成了一个函数表达式 ,我们可以在表达式的末尾使用圆括号来执行这个函数表达式。这种表达式被称作 立即执行表达式,有一个简化叫法 IIFE

一个最简单的立即执行函数表达式如下:

(function () {})();

第一个圆括号内是一个函数表达式,第二个圆括号内时需要传入的参数。

立即执行函数表达式的优点在于它不会在当前作用域产生任何声明,内部声明的变量函数不会污染到全局,全局作用域不会因为各种声明变得混乱。比如十个人开发一个程序,如果把变量和函数全部都声明到全局上,并且代码有上万行,那么一旦出现重名的情况,程序会难以调试修改的,比如:

// 这里是第一个人写的

function foo() {
  // ...
}
// 使用 foo 写了 200 行代码

// 这里是第二个人写的
function foo() {
  // ....
}
// 使用 foo 写了 400 行代码

在他们的代码拼凑到一起前,它们并不知道使用了相同的函数名在开发,当代码合并后,前一个声明会被覆盖,前一个人使用 foo 函数就会出错。为了消除这种影响,就可以使用立即执行表达式:

(function () {
  // 这里是第一个人写的

  function foo() {
    // ...
  }
  // 使用 foo 写了 200 行代码,
})();

(function () {
  // 这里是第二个人写的
  function foo() {
    // ....
  }
  // 使用 foo 写了 400 行代码
})();

因为立即执行表达式内部产生了新的函数作用域,内部声明不会干扰到全局,所以两部分代码就可以正常运行。实际开发中,全局上我们只是存放一些模块代码,不会把业务逻辑写到全局上。

递归函数

大家应该都听过这么一个故事:

  1. 从前有座山,山里有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲故事,讲的是:
  2. 从前有座山,山里有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲故事,讲的是:
  3. 从前有座山,山里有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲故事,讲的是:
  4. ......

现在我们定义一个函数,用来“讲讲”这个故事:

// 讲故事函数
function tellStory() {
  console.log("从前有座山,山里有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲故事,讲的是:");
  tellStory();
}

tellStory(); // 开始讲

【代码解释】

上面的代码中,我们让计算机讲述了这个故事,这个故事是讲不完的,程序会因为一直讲这个故事而被挂起,直到内存溢出报错。这种函数调用自己的方式,我们称作 递归

递归函数用来处理逻辑的循环,和 for 循环类似,但是思维的点不一样,递归是在上一段逻辑的基础上执行下一段相同的逻辑,是相同逻辑的嵌套,而 for 循环更适合是相同逻辑的并行。

使用递归函数需要注意的是,必须记住设置一个结束的逻辑,不然就会想上面的代码一样,无限执行下去,直到程序报错。我们可以尝试使用递归来遍历一个数组:

var foo = [1, 2, 3, 4, 5];

function loop(a, i) {
  i = i || 0;

  if (i < a.length) {
    console.log(a[i]);
    loop(a, i + 1);
  }
}

loop(foo);

在上面代码中,我们使用递归函数遍历了数组。在函数执行的时候,我们设置了一个下标用来追踪当前遍历的位置,递归成立的条件是下标不超过数组长度。

备注:递归非常耗费计算机性能,这在任何编程语言中都成立。如果使用递归的层数极高,比如上万层递归的情况,并不建议使用。

回调函数

前面介绍过,函数是引用类型,所以函数也是一个数据,函数可以赋值给变量或属性,并且相互传递:

function foo() {
  console.log("foo function");
}
var bar = foo;
var baz = bar;
baz(); // "foo function"

上面代码中,我们把 foo 函数当做数据在变量之间传递,这种传递不仅仅可以在变量之间,还可以在参数传递中,比如:

function foo() {
  console.log("this is foo");
}

function doFn(fn) {
  fn();
}

doFn(foo);

上面代码会打印 this is foo,我们把 foo 当做参数传递给 bar 函数,bar 函数通过参数 fn 来接收。这种把函数当做参数传递给另一个函数的行为称作 回调,被传递的函数称作 回调函数

回调函数的作用在于,你可以在其他函数中,执行其他函数的逻辑。

比如我们需要编写一个数组的过滤器,通过某种规则,过滤掉数组中的部分数据:

function filterArray(arr, rule) {
  var re = [];
  for (var i = 0; i < arr.length; i++) {
    if (rule(arr[i])) {
      re[re.length] = arr[i];
    }
  }
  return re;
}

var foo = [1, 2, 3, "A", "B", "C", { name: "foo" }, [1, 2, 3]];
var filterRe = filterArray(foo, function (val) {
  return typeof val === "string";
});
console.log(filterRe);

上述代码中,我们自定义了过滤的规则,使用回调函数的方式传入。最后得到一个只有字符串类型数据的数组。

练习

  • 题 1:编写一个递归函数,传入正整数 n,返回菲波那切数列的第 n 项,关于斐波那契数列可以到网上查询其规律。

  • 题 2:在新标准的 JS 中,数组有一个内置函数 forEach,它被用来遍历数组,用法如下:

    var arr = ["A", "B", "C", "D"];
    arr.forEach(function (val, index) {
      console.log(val, index);
    });
    

    上述代码会依次打印 arr 数组项的值和索引下标,尝试编写一个函数 myForEach 模拟内置函数 forEach 的功能。它有两个参数,第一个参数是被操作的数组,第二个参数是遍历的回调函数。

题 1

基于菲波那切数列的公式,其递归实现如下:

function fib(n) {
  if (n <= 1) {
    return 1;
  } else {
    return fib(n - 1) + fib(n - 2);
  }
}
// 可以通过三目运算符简化:
function fib2(n) {
  return n <= 1 ? 1 : fib2(n - 1) + fib2(n - 2);
}

注意,递归非常耗费性能,在流程控制章节我们使用 for 循环也编写过菲波那切数列,for 循环非常快,递归之所以慢,是因为递归的层数过多。上面代码的递归层数如下:

fib(0) 执行 1 次函数:

fib(0) =&gt; 1

fib(1) 执行 1 次函数:

fib(1) =&gt; 1

fib(2) 执行 3 次函数:

fib(2) =&gt; fib(1) + fib(0)
       =&gt; 1 + 1
       =&gt; 2

fib(3) 执行 5 次函数:

fib(3) =&gt; fib(2) + fib(1)
       =&gt; fib(1) + fib(0) + 1
       =&gt; 1 + 1 + 1
       =&gt; 3

fib(4) 执行 9 次函数:

fib(4) =&gt; fib(3) + fib(2)
       =&gt; fib(2) + fib(1) + fib(1) + fib(0)
       =&gt; fib(1) + fib(0) + 1 + 1 + 1
       =&gt; 1 + 1 + 3
       =&gt; 5

fib(5) 执行 15 次函数:

fib(5) =&gt; fib(4) + fib(3)
       =&gt; fib(3) + fib(2) + fib(2) + fib(1)
       =&gt; fib(2) + fib(1) + fib(1) + fib(0) + fib(1) + fib(0) + 1
       =&gt; fib(1) + fib(0) + 1 + 1 + 1 + 1 + 1 + 1
       =&gt; 1 + 1 + 6
       =&gt; 8

......

题 2

function myForEach(arr, fn) {
  for (var i = 0; i < arr.length; i++) {
    fn(arr[i], i);
  }
}