JavaScript 系列博客(三)

前言

本篇介绍 JavaScript 中的函数知识。

函数的三种声明方法

function 命令

可以类比为 python 中的 def 关键词。

function 命令声明的代码区块,就是一个函数。命令后面是函数名,函数名后面的圆括号里面是要传入的形参名。函数体放在大括号里面。

function fn(name) {
    console.log(name);
}

使用 function 命名了一个 fn 函数,以后可以通过调用 fn 来运行该函数。这叫做函数的声明(Function Declaration)。

函数表达式

除了使用 function 命令声明函数外,可以采用变量赋值的写法。(匿名函数)

var fn = function(name) {
    console.log(name);
};

这种写法将一个匿名函数赋值给变量。这时,这个匿名函数又称之为函数表达式(Function Expression),因为赋值语句的等号右侧只能放表达式。

采用函数表达式声明函数时,function 命令后面不带有函数名。如果加上函数名,该函数名只能在函数体内访问,在函数体外部无效。

var fn = function x(name) {
    console.log(typeof x);
};
x
// ReferenceError: x is not defined
fn();
// function

声明函数时,在函数表达式后加了函数名 x,这个 x 只可以在函数内部使用,指代函数表达式本身。这种写法有两个用处:一可以在函数体内部调用自身;二方便debug(debug 显示函数调用栈时,会显示函数名)。需要注意的是,函数表达式需要在语句的结尾加上分号,表示语句结束。而函数的声明在结尾的大括号后面不用加分号。

Function 构造函数

第三种声明函数的方法是通过构造函数,可以理解为 python 中的函数类,通过传入参数并且返回结果就可以创建一个函数。

JavaScript 系列博客(三)-LMLPHP

构造函数接收三个参数,最后一个为 add函数的‘’函数体‘’,其他参数为add 函数的参数。可以为构造函数传递任意数量的参数,不过只有最后一个参数被当做函数体,如果只有一个参数,该参数就是函数体。

Function 构造函数也可以不用 new 命令,结果一样。这种声明函数的方式不直观,使用概率很少。

函数的调用

和 python 一样,调用一个函数通过圆括号,圆括号中是要传入的实参。

函数体内部的 return 语句,表示返回。JavaScript 引擎遇到 return 时,就直接返回 return 后面表达式的值(和 python 一样),所以 return 后面的代码是无意义的,如果没有 return 那么就会返回 undefined(python 中返回 None)。

函数作用域

作用域的定义

作用域指的是变量存在的范围。在 ES5中,JavaScript 只有两种作用域:一种是全局作用域,变量在整个程序中一直存在,任意位置可以访问到;另一种是函数作用域,也称之为局部作用域,变量只有在函数内部才能访问到。ES6新增了块级作用域,等价于局部作用域一样,就是新增了一种产生局部作用域的方式。通过大括号产生块级作用域。

在函数外部声明的变量就是全局变量,可以在任意位置读取。

在函数内部定义的变量,外部无法读取,只有在函数内部可以访问到。并且函数内部定义的同名变量,会在函数内覆盖全局变量。

注意:对于 var 命令来说,局部变量只可以在函数内部声明,在其他区块中声明,一律都是全局变量。ES6中声明变量的命令改为 let,在区块中声明变量产生块级作用域。

函数内部的变量提升

与全局作用域一样,函数作用域也会产生‘’变量提升‘’现象。var 命令生命的变量,不管在什么位置,变量声明都会被提升到函数体的头部。

function foo(x) {
    if (x > 100) {
        var tmp = x - 100;
    }
}

// 等同于
function foo(x) {
    var tmp;
    if (x > 100) {
        tmp = x - 100;
    }
}

函数本身的作用域

函数和其他值(数值、字符串、布尔值等)地位相同。凡是可以使用值得地方,就可以使用函数。比如,可以把函数赋值给变量和对象的属性,也可以当做参数传入其他函数,或者作为函数的结果返回。函数是一个可以执行的值,此外没有特殊之处。

函数也有自己的作用域,函数的作用域称为局部作用域。与变量一样,就是其生命时所在的作用域,与其运行时所在的作用域无关(闭包、装饰器)。通俗地讲就是在定义函数的时候,作用域已经就确定好了,那么在访问变量的时候就开始从本作用域开始查找,而与函数的调用位置无关。

var x = function () {
    var a = 1;
    console.log(a);
};
function y() {
    var a = 2;
    x();
}
y(); // 1

JavaScript 系列博客(三)-LMLPHP

函数 x 是在函数 f 的外部生命的,所以它的作用域绑定外层,内部变量 a 不会到函数 f 体内取值,所以输出1,而不是2。

总之,函数执行时所在的作用域,是定义时的作用域,而不是调用时所在的作用域。

函数参数

调用函数时,有时候需要外部传入的实参,传入不同的实参会得到不同的结果,这种外部数据就叫参数。

参数的省略

在 JavaScript 中函数参数不是必需的,就算传入的参数和形参的个数不相等也不会报错。调用时无论提供多少个参数(或者不提供参数),JavaScript 都不会报错。省略的参数的值变为 undefined。需要注意的是,函数的 length 属性值与实际传入的参数个数无关,只反映函数预期传入的参数个数。

但是,JavaScript 中的参数都是位置参数,所以没有办法只省略靠前的参数,而保留靠后的参数。如果一定要省略靠前的参数,只有显示的传入 undefined。

传递方式

函数参数如果是原始类型的值(数值、字符串、布尔值),传递方式是传值传递(pass by value)。这意味着,在函数体内修改参数值,不会影响到函数外部(局部变量的修改不会影响到全局变量:对于基本数据类型)。

但是,如果函数参数是复合类型的值(数组、对象、其他函数),因为传值方式为地址传递(pass by reference)。也就是说,传入函数的原始值的地址,因此在函数内部修改参数,将会影响到原始值。

注意:如果函数内部修改的不是参数对象的某个属性,而是直接替换掉整个参数,这时不会影响到原始值。

var obj = [1, 2, 3];

function f(o) {
    o = [2, 3, 4];
}
f(obj);

obj // [1, 2, 3]

上面代码,在函数 f 内部,参数对象 obj 被整个替换成另一个值。这时不会影响到原始值。这是因为,形式参数(o)的值实际上是参数 obj 的地址,重新对o 赋值导致 o 指向另一个地址,保存在原地址上的数据不会被改变。

同名参数

如果有同名的参数,则取最后出现的那个值。

function f(a, a) {
    console.log(a);
}

f(1, 2) // 2

上面代码中,函数 f 有两个参数,且参数名都是 a。取值的时候,以后面的 a 为准,即使后面的a 没有值或被省略,也是以其为准。

function f(a, a) {
    console.log(a);
}
f(1) // undefined

调用函数 f 时,没有提供第二个参数,a 的取值就变成了 undefined。这时,如果要获得第一个 a 的值,可以使用 arguments 对象(类比linux 中的arg)。

function f(a, a) {
    console.log(arguments[0]);
}

f(1) // 1

arguments 对象

定义

由于 JavaScript 允许函数有不定数目的参数,所以需要一种机制,可以在函数体内部读取所有参数。这就是 arguments 对象的由来。

arguments 对象包含了函数运行时的所有参数,arguments[0]就是第一个参数,以此类推。注意:该对象只有在函数体内部才可以使用。

正常模式下,arguments 对象可以在运行时修改。

var f = function(a, b) {
    arguments[0] = 3;
    arguments[1] = 3;
    return a + b;
}
f(1, 1) // 5

上面代码中,调用 f 时传入的参数,在函数体内被修改了,那么结果也会修改。

严格模式下,arguments 对象是一个只读对象,修改它是无效的,但不会报错。

var f = function(a, b) {
    'use strict'; // 开启严格模式
    arguments[0] = 3; // 无效
    arguments[1] = 2; // 无效
    return a + b;
}

f(1, 1) // 2

开启严格模式后,虽然修改参数不报错,但是是无效的。

通过 arguments 对象的 length 属性,可以判断函数调用时到底带几个参数。

function f() {
    return arguments.length;
}
f(1, 2, 3) // 3
f(1) // 1

与数组的关系

需要注意的是,虽然 arguments 很像数组,但它是一个对象。数组专有的方法(比如 slice 和 forEach),不能再 arguments 对象上直接使用。

如果要让 arguments 对象使用数组方法,真正的解决方法是将 arguments 转为真正的数组。下面是两种常用的转换方法:slice 方法和逐一填入新数组。

var args = Array.prototype.slice.call(arguments);

// var args = [];
for (var i = 0; i < arguments.length; i++) {
    args.push(arguments[i]);
}

callee 属性

arguments 对象带有一个 callee 属性,返回它所对应的原函数。

var f = function() {
    console.log(arguments.callee === f);
}
f(); // true

可以通过 arguments.callee,达到调用自身的目的。这个属性在严格模式里面是禁用的,不建议使用。

函数闭包

闭包是所有编程语言的难点,在 python 中闭包的多应用于装饰器中。在 JavaScript 中闭包多用于创建作用域,或者解决变量污染的问题。

理解闭包,首先需要理解变量作用域。在 ES5中,JavaScript 只有两种作用域:全局作用于和函数作用域。函数内部可以直接读取全局变量。

var n = 999;

function f1() {
    console.log(n);
}
f1(); // 999,n是全局变量,可以被访问到

但是函数外部无法读物函数内部声明的变量。

function f1() {
    var n = 999;
}
console.log(n);
// Uncaught ReferenceError: n is not defined

因为变量作用域的关系,在外部需要访问到局部变量在正常情况下是做不到的,这就可以通过闭包来实现。下来来看一个经典例子:循环绑定事件产生的变量污染

<div class="box">
    0000001
</div>
<div class="box">
    0000002
</div>
<div class="box">
    0000003
</div>
<script>
    var divs = document.querySelectorAll(".box");
    // 存在污染的写法
    for (var i =0; i < divs.length; i++) {
        divs.onclick = function () {
            console.log('xxx', i)
        }
    }
    // 运行结果显示4
</script>

会产生变量污染的原因是作用域,因为 var 并不产生作用域,所以在 for循环中的变量就是全局变量,只要 for循环结束那么 i 的值就确定了,除非在极限情况下,你的手速比 cpu 还要快,那么可能会看到小于4的值。这样的问题可以通过函数的闭包来解决。产生新的作用域用来保存 i 的值。

for (var i = 0; i < divs.length; i++) {
    (function () {
        var index = i;
        divs[index].onclick = function () {
            console.log('xxx', index);
        }
    })()
}
// 另一种版本
for (var i = 0; i < divs.length; i++) {
    function(i) {
        divs[i].onclick = function () {
            console.log('yyy', i)
        }
    }(i)
}

利用闭包原理产生新的作用域用来保存变量 i 的值,这样就解决了变量污染的问题,还有利用ES6的声明变量关键词 let,也会产生新的作用域(块级作用域)也可以解决变量污染的问题。

在 JavaScript 中,嵌套函数中的子函数中可以访问到外部函数中的局部变量,但是外部函数访问不到子函数中的局部变量,这是 JavaScript 中特有的‘’链式作用域‘’结构(python 也一样),子对象会一级一级的向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。可以简单地把闭包理解为‘’定义在一个函数内部的函数‘’,闭包最大的特点就是它可以‘’记住‘’诞生的环境,在本质上闭包就是将函数内部和函数外连接起来的一座桥梁。

必报的最大用处有两个,一个是可以读取函数内部的变量,另一个就是让这些变量始终保持在内存中,即闭包可以使得它诞生的环境一直存在。下面的例子:

function createIncrementor(start) {
    return function () {
        return start++;
    };
}

var inc = createIncrementor(5);

inc(); // 5
inc(); // 6
inc(): // 7

上面代码中,start 是函数 createIncrementor 的内部变量。通过闭包,start 的状态被保存,每一次调用都是在上一次调用的基础上进行计算。从中可以看出,闭包 inc 使得函数 createIncrementor 的内部环境一直存在。所以闭包可以看做是函数内部作用域的一个接口。为什么会这样呢?原因就在于 inc 始终在内存中,而 inc 的存在依赖于 createIncrementor,因此也一直存在于内存中,不会再外层函数调用结束后 start 变量被垃圾回收机制回收。

闭包的另外一个用处是封装对象的私有属性和私有方法。(这部分还不太懂,还需要琢磨)

function Person(name) {
  var _age;
  function setAge(n) {
    _age = n;
  }
  function getAge() {
    return _age;
  }

  return {
    name: name,
    getAge: getAge,
    setAge: setAge
  };
}

var p1 = Person('张三');
p1.setAge(25);
p1.getAge() // 25

上面代码中,函数 Person 的内部变量_age,通过闭包 getAge 和 setAge,变成了返回对象p1的私有变量。

注意:外城函数每次运行,都会产生一个新的闭包,而这个闭包又会保留外城函数的内部变量,所以内存消耗很大。

12-21 04:23