我们说过函数隶属于对象。即函数是一个对象。在对象的基础上函数添加了许多特性,基于对特性的使用,我们把函数分为三类
- 普通对象
- 普通函数
- 构造函数(类)
函数对象
let fnObj = function () { }
// 添加对象属性
fnObj.name = 'wcdaren'
fnObj.age = 22
fnObj.sayHi = function () {
console.log('Hi!');
}
console.log(fnObj.name); //fnObj
console.log(fnObj.age);//22
fnObj.sayHi(); // Hi!
delete fnObj.age
console.log(fnObj.age);//undefined
上面创建函数的方式被称为函数表达式。关于函数表达式我们还会在后面定义函数再讲到。
普通函数
使函数不同于其他普通对象的绝对性特点是函数存在一个[[Call]]内部属性。
**[[Call]]**属性是函数独有的,表明该对象可以被执行。由于仅函数拥有该属性,ECMAScript定义typeof操作符对任何具有[[Call]]属性的对象返回“function”。
function add(a, b) {
return a + b
}
console.log(typeof add); //function
这也让很多人误以为函数与对象并列为数据类型的原因。但是函数是隶属于对象的。
定义函数
函数声明
一个函数定义(也称为函数声明,或函数语句)由一系列的function关键字组成,依次为:
- 函数的名称。eg:add
- 函数参数列表,包围在括号中并由逗号分隔。eg:(a,b)
- 定义函数的 JavaScript 语句,用大括号
{}括起来。eg:{return a + b}
例如,以下的代码定义了一个简单的add函数:
function add(a, b) {
return a + b
}
console.log(typeof add); //function
函数add有两个参数分别是a和b,这个函数只有一个语句,它表明该函数 将 函数的参数(即a和b)相加后返回。函数的return语句确定了函数的返回值:
return a + b;
函数表达式
虽然上面的函数声明在语法上是一个语句,但函数也可以由函数表达式创建。这样的函数可以是匿名的,它不必有一个名称,我们称这样的函数为匿名函数。例如,我们在普通对象里创建的函数对象。
let fnObj = function () { }
我们把上面通过函数声明的add函数改成函数表达式
let add = function (a, b) {
return a + b
}
然而,函数表达式也可以提供函数名,并且可以用于在函数内部代指其本身,或者在调试器堆栈跟踪中识别该函数:
var factorial = function fac(n) {
return n < 2 ? 1 : n * fac(n - 1)
};
console.log(factorial(3)); // 6
当将函数作为参数传递给另一个函数时,函数表达式很方便。
比如我们的sort排序函数就接受一个函数作为参数。我们就可以通过函数表达式定义函数,把它作为参数传递进去。
let arr = [3, 2, 1]
console.log(arr); //[ 3, 2, 1 ]
let asceFn = function (a, b) {
return a - b
}
arr = arr.sort(asceFn)
// 等价于下面
// arr = arr.sort(function (a, b) { return a - b })
console.log(arr); //[ 1, 2, 3 ]
在 JavaScript 中,可以根据条件来定义一个函数。比如下面的代码,当age >= 18 的时候才会定义 palyFn :
let age = 18
let playFn
if (age >= 18) {
playFn = function () {
console.log('可以播放');
}
}
playFn() //可以播放
let age = 11
let playFn
if (age >= 18) {
playFn = function () {
console.log('可以播放');
}
}
console.log(playFn); //undefined
playFn() // 报错 playFn is not a function
为什么要提上面这个呢?因为相对与函数表达式,作为函数声明存在函数提升的机制。
函数提升
函数声明和var创建变量一样,会被提升至上下文(要么是该函数被声明时所在的函数的范围(即函数域),要么是全局范围)的顶部。但值得值得的是:
- 变量提升只是声明变量却未定义
- 函数提升时该函数已被声明且定义
console.log(obj); //undefined
console.log(fn); //[Function: fn]
var obj = 1
function fn() {
console.log('Hi');
}
因为这样我们就可以在函数定义前调用函数,关于调用函数将在下面讲解。
fn() //Hi
function fn() {
console.log('Hi');
}
那就会引入一个新的问题,怎么判断该函数是函数声明还是函数表达式呢?
区分函数声明和表达式最简单的方法是:看 function 关键字前面有无其他词,如果有就是函数表达式,没有的话就是函数声明
console.log(fn); //undefined
fn() // 报错
var fn = function fn() {
console.log('Hi');
}
console.log(fn); //报错 fn is not defined
(function fn() {
console.log('Hi');
})();
上面这里是一个立即执行函数,我们将在函数作用域里再次讨论
箭头函数
在ES6后,我们又多了一种定义函数的方式,即箭头函数。需要注意的是,箭头函数有几种不同的写法。以下代码中的箭头函数和前面所见的匿名函数非常相似。不同之处在于缺少了 function 关键字,并且参数列表的右侧多了一个箭头 =>。
let add = (a, b) => {
return a + b
}
console.log(add(1, 2)); //3
console.log(typeof add); // function
简写
- 只有一个参数的时候
var sayHi = name => {
console.log(`I'm ${name}`);
}
sayHi('wcdaren') //I'm wcdaren
// 等效于
var sayHi = (name) => {
console.log(`I'm ${name}`);
}
sayHi('wcdaren') //I'm wcdaren
- 函数只有return语句
var add = (a, b) => a + b
console.log(add(1, 2)); //3
// 等效于
var add = (a, b) => {
return a + b
}
console.log(add(1, 2)); //3
区别
虽然箭头函数看起来和常规的匿名函数很相似,但它们本质上完全不同:
- 箭头函数不能显式地命名,尽管现代运行环境会将箭头函数所赋予的变量名作为函数名;
- 箭头函数不能用作构造函数,也没有 prototype 属性,这意味着不能对它们使用 new 关键字;
- 箭头函数没有arguments对象,但是可以访问包含它的函数的arguments对象
- 此外,箭头函数会绑定到所在词法作用域中,因此它们不会改变 this 的指向。
这些区别 我们会在后面慢慢介绍
调用函数
定义一个函数并不会自动的执行它。定义了函数仅仅是赋予函数以名称并明确函数被调用时该做些什么。调用函数才会以给定的参数真正执行这些动作。例如,一旦你定义了函数add,你可以如下这样调用它:
function add(a, b) {
return a + b
}
let add1 = function (a, b) {
return a + b
}
let add2 = (a, b) => { return a + b }
// 如果函数体只有return 我们可以用下面的简写
let add3 = (a, b) => a + b
console.log(add(1, 2));//3
console.log(add1(1, 2));//3
console.log(add2(1, 2));//3
console.log(add3(1, 2));//3
上述语句通过提供参数 1和2 来调用函数。函数执行完它的语句会返回值3。
参数
JavaScript函数的另一个独特之处在于你可以给函数传递任意数量的参数却不造成错误。
let fn = function (a, b, c) {
return a
}
console.log(fn(1, 2, 3, 4, 5));//1
那是因为函数参数实际上被保存在一个被称为arguments的类似数组的对象中。如同一个普通的JavaScript数组,arguments可以自由增长来包含任意个数的值,这些值可通过数字索引来引用。arguments的length 属性会告诉你目前有多少个值。
arguments对象自动存在于函数中。也就是说,函数的命名参数不过是为了方便,并不真的限制了该函数可接受参数的个数。
使用 arguments 对象
在介绍 arguments前,我们要知道一点箭头函数没有arguments对象。
在函数内,你可以按如下方式找出传入的参数:
arguments[i]
其中i是参数的序数编号(即,数组索引),以0开始。所以第一个传来的参数会是arguments[0]。参数的数量由arguments.length表示。
使用arguments对象,你可以处理比声明的更多的参数来调用函数。这在你事先不知道会需要将多少参数传递给函数时十分有用。你可以用arguments.length来获得实际传递给函数的参数的数量,然后用arguments对象来取得每个参数。
例如,设想有一个用来连接字符串的函数。唯一事先确定的参数是在连接后的字符串中用来分隔各个连接部分的字符。该函数定义如下:
function join(separator) {
let ret = ''
// 注意这里我们是从1开始 ,因为arguments[0]是separator
for (let i = 1; i < arguments.length; i++) {
ret += arguments[i] + separator
}
return ret
}
console.log(join(', ', 'first', 'second', 'third')); //first, second, third,
我们发现 最后的 third 后面还跟着一个 , 如果我们不要这个逗号还需要特殊处理,那能不能有更优雅的方式呢?那就是数组的join
console.log(['first', 'second', 'third'].join(','));
// first,second,third
但是 arguments变量只是 类数组对象,并不是一个数组。称其为类数组对象是说它有一个索引编号和length属性。尽管如此,它并不拥有全部的Array对象的操作方法。
Array.isArray(arguments)永远返回false。
function fn(a, b, c) {
console.log(arguments); //[Arguments] { '0': 1, '1': 2 }
console.log(typeof arguments); //object
console.log(Array.isArray(arguments)); //false
}
fn(1, 2)
更多信息请阅读arguments
将类数组转换为数组
Array#slice.call
那既然不是数组,我们就把它变成数组,通过 Array#slice.call方法 可以把arguments对象转换为真正的数组,那么我们可以调用数组中的join方法
关于call 我们会在构造函数再讲到
function join() {
var list = Array.prototype.slice.call(arguments)
return list.join(', ')
}
console.log(join('first', 'second', 'third'));
// 'first, second, third'
扩展运算符
扩展运算符可以将可遍历对象转换为数组,能够在数组或函数调用中轻松展开表达式。以下示例使用 ...arguments 将函数参数转换为一个数组字面量。
function join() {
console.log([...arguments]);
// [ 'first', 'second', 'third' ]
console.log(Array.isArray([...arguments]));
// true
return [...arguments].join(', ')
}
console.log(join('first', 'second', 'third'));
// first, second, third
剩余参数
在上面类数组的问题里,通过 Array#slice.call或者扩展运算符只是表面解决遍历的问题,即把类数组转换为数组。此外,我们还知道箭头函数没有arguments对象,那对于箭头函数如何实现上述的功能呢?
在ES6中添加了剩余参数语法允许将不确定数量的参数表示为数组,且可在箭头函数中使用。
在函数的最后一个参数前添加 ... 可以将该参数转变为一个特殊的“剩余参数” 。当剩余参数是函数中的唯一参数时,它会获取所有传入函数的参数。这与上述使用 .slice 处理的结果相同,但不需要依赖于 arguments,直接在参数列表中指定即可。
function join(...list) {
return list.join(', ')
}
console.log(join('first', 'second', 'third'));
//first, second, third
let join = (...list) => list.join(', ')
console.log(join('first', 'second', 'third'));
//first, second, third
这里尽管参数 …list 只有一个参数,但是因为添加了剩余参数是不可以简写去掉
( )的
剩余参数前面的参数不会包含在list参数中
function join(separator, ...list) {
return list.join(separator)
}
console.log(join(', ','first', 'second', 'third'));
//first, second, third
默认参数
在JavaScript中,函数参数的默认值是undefined。然而,在某些情况下设置不同的默认值是有用的。这时默认参数可以提供帮助。
在过去,用于设定默认的一般策略是在函数的主体测试参数值是否为undefined,如果是则赋予一个值。如果在下面的例子中,调用函数时没有实参传递给b,那么它的值就是undefined,于是计算a*b得到、函数返回的是 NaN:
function multiply(a, b) {
b = (typeof b !== 'undefined') ? b : 1;
return a*b;
}
multiply(5); // 5
使用默认参数,在函数体的检查就不再需要了。现在,你可以在函数头简单地把1设定为b的默认值:
function multiply(a, b = 1) {
return a*b;
}
multiply(5); // 5
箭头函数的参数也可以指定默认值。当为箭头函数的参数指定默认值时,哪怕只有一个参数,也需要用圆括号将参数列表包裹起来。
var double = (input = 0) => input * 2
与某些编程语言不同,我们可以为任何一个参数设置默认值,而不是只能给最后一个参数设置。
function sumOf(a = 1, b = 2, c = 3) {
return a + b + c
}
console.log(sumOf(undefined, undefined, 4))
// <- 1 + 2 + 4 = 7
在 JavaScript 中,向函数传递包含多个属性的 options 对象参数是再常见不过的。如果调用函数时没有传入,可以为其设定一个默认值对象 options,如下所示。
let defaultOptions = { name: '无名氏', age: 0 }
function person(per = defaultOptions) {
console.log(per.name);
console.log(per.age);
}
person() // 无名氏 0
该方法存在一个问题,如果 person 的使用者传入一个 options 对象,则所有的默认值都会失效。
let defaultOptions = { name: '无名氏', age: 0 }
function person(per = defaultOptions) {
console.log(per.name);
console.log(per.age);
}
person({ age: 22 }) // undefined 22
了解更多默认参数的信息。
解构
与只提供一个默认值相比,更好的方法是解构整个 options,并在解构模式中为每个属性指定默认值。通过使用这个方法,我们不通过 options 对象就能引用 options 中的每个选项,但也因此不再能够直接引用 options,这在某些情况下可能会产生问题。
function person({ name = '无名氏', age = 0 }) {
console.log(name);
console.log(age);
}
person({ age: 22 }) //无名氏 22
在这种情况下,如果使用者没有传入 options 对象,则默认值会再次缺失。也就是说,如果没有传入 options 对象参数,person 就会报错。为 options 添加一个空对象作为默认值即可避免该问题,如下所示。这是因为解构空对象时已经提供了默认值。
function person({ name = '无名氏', age = 0 }) {
console.log(name);
console.log(age);
}
person() //报错 TypeError: Cannot destructure property `name` of 'undefined' or 'null'.
function person({ name = '无名氏', age = 0 } = {}) {
console.log(name);
console.log(age);
}
person() //无名氏 0
除了默认值,我们还可以在函数参数中使用解构来描述函数能够处理的对象结构。思考以下代码,假设有一个包含多个属性的 person 对象。person 对象描述了其姓名、年龄、配偶、生日以及学历。
let person = {
name: {
lastName: 'gao',
firstName: 'wcdaren',
},
age: 22,
partner: 'gugu',
date: '1996-07-15',
qualification: '本科'
}
如果只想在某个函数中提取对象的某些属性作为参数,可以通过解构提前显式地引用这些属性。这样做的好处是,看到函数声明时,就能知道函数需要使用哪些属性。 提前解构所需要的每个属性时,当输入不正确时,就很容易被发现。以下示例展示了如何在参数列表中指定需要的所有属性,这样我们对 getPersonNameAndAge API 能够处理的参数就一目了然了。
let person = {
name: {
lastName: 'gao',
firstName: 'wcdaren',
},
age: 22,
partner: 'gugu',
date: '1996-07-15',
qualification: '本科'
}
function getPersonNameAndAge({ name, age }) {
console.log(name.firstName);
console.log(age);
}
getPersonNameAndAge(person) // wcdaren 22
参数传递
我们再回到函数的调用,在函数的调用中,如果有参数传递,就有一个将实参(actual parameter)映射到形参(formal parameter)的过程。如何把实参映射到行参,就有不同的机制,常见的有以下两种
-
按值传递(pass-by-value 或 call-by-value)
函数收到的是实际参数的值——按位拷贝(bitwise copy)
-
按引用传递(pass-by-reference 或 call-by-reference)
函数收到的是实际参数的引用——内存地址
此外还有拷贝-恢复(copy-restore)、按名调用(call-by-name)、宏展开(macro expansion)等机制
我们写一段代码来思考
function change(obj) {
obj = { name: '忘尘' }
}
let obj = { name: 'wcdaren' }
change(obj)
console.log(obj);
{ name: 'wcdaren' }是一个object引用类型,如果js是按引用将obj传递给change,即传递的是obj自身的地址,即CCCFFF000。那change函数里的
obj = { name: '忘尘' }
就会改变CCCFFF000这个地址上obj的值,即把AAAFFF111指向另一个对象。
但是,输出的结果是wcdaren。即,js中不是按引用传递的。因为函数里对形参的改变,并没有影响到实参。实际上JS,只是把obj的值,即AAAFFF111拷贝了一份,赋给了形参。
从图中我们能更清楚的,函数传递,只是把实参的值,拷贝一份给形参,即形参的改变是无法影响实参的。
但是,我们却可以改变传入对象的内容
function change(obj) {
obj['age'] = 22
}
let obj = { name: 'wcdaren' }
change(obj)
console.log(obj); //{ name: 'wcdaren', age: 22 }
从图中,可以很明显的看出,因为实参与行参都指向同一个对象,当形参对obj对象的修改,会影响到实参的输出。
这里分享一篇知乎关于两者传递方式的讨论,虽然里面讨论的是java,但是js和java一样,参数传递都是只有按值传递:链接
《你不知道的javascript(中)》书中于提到的通过值复制或者 通过引用复制,实际上都是上述所说的按值传递,只不过是把值传递终点的值再一次细分为,简单值(scalar primitive)和复合值(compound value)
函数作为参数
我们在上面说到了按值传递,接下来我们讨论一下这个值字。既然谈到值,就不可避免的要提到它的对立面,引用。那什么是值什么是引用呢?
- 值:具有某种类型的数据
- 引用:可用来获取特定数据的值
如果把类型做以区分,就有值类型,和引用类型。
- 值类型:能直接方法的数据类型,如 我们说到的 11数字,'wcdaren'字符串
- 引用类型: 借助引用才能被访问的数组类型,如 ,对象。
因为在js中,变量是没有类型的 obj 此时可以存储一个引用类型,即指向一个对象,我们也可以给他重新赋值,存储一个值类型。
function change(obj) {
obj['age'] = 22
}
let obj = { name: 'wcdaren' }
change(obj)
console.log(typeof obj); //object
obj = 22
console.log(typeof obj); //number
既然变量是无类型的,那对于变量来说,存储什么数据都是一样的,所以对于变量自身而言,它存储的是一个值类型还是一个引用类型,它是不知道的,它只是知道,它存储了一个值(此值包括值类型和引用类型)。
那按变量的视角来看,“所有的东西都是值”。
既然函数是一个对象,那函数的存储也应该如上图所示,既然我们可以按值传递把对象传递进去,那函数也可以作为参数传递进去。
高阶函数
在函数式语言中(JS中),我们把以函数作为参数的函数或者函数最终返回(return)一个函数的函数称为高阶函数。
最常见的莫过于排序函数
let arr = [2, 1, 11, 5, 19]
console.log(arr);
// [ 2, 1, 11, 5, 19 ]
arr.sort((a, b) => a - b)
console.log(arr);
// [ 1, 2, 5, 11, 19 ]
关于高阶函数,我们还会单独开一篇来讲
形参赋值与函数提升
我们在前面说到了js是按值传递的,这个传递实际上就是在函数内部创建一个变量来接收传递进来的值。
既然形参是作为一个变量存在,那么let就不能重复定义了。
var name = '忘尘'
function sayHi(name) {
let name
console.log(`I'm ${name}`);
}
sayHi(name) //报错: Identifier 'name' has already been declared
变量提升中,只是提前定义变量名,并为定义,如果变量名存在,就不重复定义。
那此时如果函数作为参数,且我们函数里又存在函数声明,就会涉及到是形参赋值先还是函数提升先的问题。
var fn1 = function () {
console.log('fn1');
}
function test(fn1) {
fn1() //fn1
fn1 = function () {
console.log('fn2');
}
fn1() //fn2
}
test(fn1)
从上面代码中,可以看出是形参赋值先于函数提升。
表达式参数
var i = 100
console.log(i += 20, i *= 2, 'value: ' + i);
// 120 240 'value: 240'
console.log(i);
// 240
js允许表达式作为参数,那既然我们说参数是按值传递的,那么如果参数是一个表达式的话,为了保持按值传递,我们必须对表达式进行运算方可得到值,所以最后的输入结果就如上所示。
我们又称这为非惰性求值。对于函数来说,如果一个参数是需要用到时,才会完成求值(或取值),那么他就是“惰性求值”,反之则是“非惰性求值”。
重载
既然说到函数的参数,就不得不提一下在大多数面向对象语言中的重载这个概念。
重载指一个函数具有多个签名。
函数签名是由以下部分组成
- 函数的名字
- 参数的个数
- 参数的类型
function sayHi() {
console.log('Hi!');
}
function sayHi(name = '无名氏') {
console.log(`Hi!I'm ${name}`);
}
sayHi()
sayHi('wcdaren')
假设具有重载特性,以上代码就会输出 Hi!和Hi!I'm wcdaren。即无参数的sayHi()执行的是第一个函数sayHi,有参数的sayHi('wcdaren')执行的是第二个函数sayHi(name = '无名氏')
但是上面代码实际输出的是Hi!I'm 无名氏和Hi!I'm wcdaren,即执行的都是第二个函数sayHi(name = '无名氏')。
其实这样的结果是显而易见,因为我们知道js函数可以接受任意数量的参数且参数类型完全没有相知,即js函数根本没有签名,既然没有签名,就根本不会有重载这种特性。
上面代码,实际上可以写成
var sayHi = function () {
console.log('Hi!');
}
sayHi = function (name = '无名氏') {
console.log(`Hi!I'm ${name}`);
}
那对于sayHi而言,它只能指向一个函数,后面的添加,只不过是对sayHi原本值的覆盖。
当然我们也可以来实现重载这种功能,我们可以通过argument对传进来的参数进行各种判断,然后来觉得该函数执行哪一代码片段,比如上面的我们可以改成
function sayHi() {
if (arguments.length === 0)
console.log('Hi');
else
console.log(`Hi!I'm ${arguments[0]}`);
}
sayHi() //Hi
sayHi('wcdaren') //Hi!I'm wcdaren
当然,这里我们是简单的通过arguments.length来判断,我们完全可以使用typeof对参数的类型进行判断,实现更加完美的重载,这里仅仅是为了告诉大家js函数是没有重载的。
函数作用域
规避冲突
我们知道在全局作用域下,存在着不同的标志符(变量,函数),我们在引进一个函数或者在自己写一个函数的时候,函数里面就会有新的标志符,如果新增的标志符不与全局下的标志符区分,那将会带来获取上的冲突,比如,变量覆盖?重复定义?
所以这个时候,我们就需要开辟一个新的作用域,即函数作用域,来存放函数里的标志符。
var wcdaren = {
age: 22
}
function sayHi() {
var wcdaren = {
age: 11
}
console.log(`I'm wcdaren,${wcdaren.age} years old`);
}
sayHi() //I'm wcdaren,11 years old
在以上的例子中,我们当然是期望输出wcdaren的,这是符合正常的人期望的.
比如读书时,1班有个小明,2班也有个小明,此时老师在1班上课,在讲台上说:小明起来回答问题。
我们决不可能说:老师!你是叫1班的小明呢还是2班的小明呢?
这是因为我们在上课时,默认是把该班作为一个整体,那函数执行的时候也是默认把函数本身{}里的东西作为一个整体,这个整体我们就称为函数作用域。
但是,如果该作用域不存在我们所需要的标志符,我们自然是会往上级作用域查找。
比如,老师说:叫忘尘过来修下电脑。
如果在班里没有忘尘这个人,我们当然会思考着忘尘这个人不是班上的人,可能是指电脑老师。
var wcdaren = {
job: '电脑老师'
}
function callWc() {
console.log(wcdaren.job);
}
callWc() //电脑老师
这说明,函数作用域是可以访问上级作用域(这里指全局作用域)下的标志符的。
更具体的访问机制,将在作用域链再介绍
反过来,在全局作用域下是无法访问函数作用域下的标志符的。
function school() {
let wcdaren = {
age: 22
};
}
console.log(wcdaren.age); //报错:wcdaren is not defined
这当然也是符合人们的期望的,比如:作为上课的老师,当然不希望在上课期间,自己的学生就被叫了出去,不晓得是做什么事去了。
立即执行函数表达式(IIFE)
从上面知道,函数内部的代码,形成了一个函数作用域,外部作用域无法访问函数内部的任意内容。避免了函数作用域中的标志符不与全局作用域的标志符产生冲突。
回归本质,可以说成,我们用函数对任意代码进行包装,避免了标志符(变量,函数)冲突。
但我们上面使用函数,都是通过函数声明的方式,这种方式会额外产生两个问题 :
- 我们在全局作用域下额外的添加了新的标志符,
- 对“隐藏”起来的代码,我们还必须显示地通过函数名才能执行里面的代码。
var wcdaren = { age: 22 }
// 添加了新的标志符 sayHi
function sayHi() {
var wcdaren = { age: 11 }
console.log(`I'm wcdaren,${wcdaren.age} years old`);
}
// 通过执行来获取sayHi函数内部的数据
sayHi() //I'm wcdaren,11 years old
而下面这段代码,就完美的解决了上述的问题。
var wcdaren = { age: 22 };
(function sayHi() {
var wcdaren = { age: 11 }
console.log(`I'm wcdaren,${wcdaren.age} years old`);
})() // I'm wcdaren,11 years old
(function foo(){ .. })():由于函数声明被包含在一对( ) 括号内部,因此成为了一个函数表达式,通过在末尾加上另外一个( )可以立即执行这个函数。
- 第一个
( )将函数声明变成函数表达式
说明不会有函数提升
- 第二个
( )执行了这个函数。
我们把(function foo(){ ... })()这种模式称为IIFE称为立即执行函数表达式(Immediately Invoked Function Expression)。
还可以写成
(function(){...}()),两者一致的。
既然是函数表达式,就如我们先前所以,可以给它添加函数名或者不添加函数名,以下代码和上面代码的执行结果一致。
var wcdaren = { age: 22 };
(function () {
var wcdaren = { age: 11 }
console.log(`I'm wcdaren,${wcdaren.age} years old`);
})() // I'm wcdaren,11 years old
虽然可以这么写,但是我们还是推荐使用具名函数(即含有函数名),因为匿名函数(即没有函数名)具有以下缺点:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的
arguments.callee引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。 - 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
参数
如果,我们把(function(){…})当作函数表达式,那我们就可以使用函数表达式(参数)来运行该函数并传入参数。
var obj = { name: 'wcdaren' };
(function IIFE(pp) {
var obj = { name: '忘尘' }
console.log('obj ' + obj.name);
console.log('pp ' + pp.name);
})(obj)
console.log('global ' + obj.name);
// obj 忘尘
// pp wcdaren
// global wcdaren
既然能传参数,那当然也可以把函数作为参数传进去 。通过这样传递,我们就可以很明显的看出执行顺序,先执行IIFE函数,然后IIFE函数执行时才会把作为参数传进来的函数执行,完美符合从上到下的阅读顺序。
var a = 1;
(function IIFE(def) {
console.log('一'); // 一
var a = 2
console.log(a); // 2
def(a)
})(function def(c) {
console.log('二'); // 二
console.log(a); // ?
console.log(c); // 2
})
// 一 2 二 ? 2
在上面这段代码中,我们都可以很明显的看出结果,而第9行console.log(a);这里的a是哪个a就会让大家困惑。是使用全局作用域下的a = 1呢还是使用IIFE作用域下的a = 2呢?为了解决这个问题,我们先学习下面的变量查找
变量查找
我们知道函数执行会形成一个新的作用域,即函数作用域。
嵌套函数
在上面的例子中,我们的上级作用域一直是全局作用域,接下来我们要讲的是如果函数是一个嵌套函数呢?
因为如果存在嵌套函数,它的上级作用域就不能简单的认为是全局作用域了。
function A(x) {
function B(y) {
function C(z) {
console.log(x + y + z);
}
C(3);
}
B(2);
}
A(1); //6
/**
* 6 (1 + 2 + 3)
*/
对于B函数和C函数,上级作用域已经不是全局作用域了。
如果我们把作用域看做是下面图中一个平面,那开辟一个新的作用域是需要建立在原来的平面(作用域)基础上。
按编程的思维是,在函数执行开辟一个作用域前,我们的函数一定是在某一作用域下定义好了的。
所以函数形成新的作用域是建立在函数定义时所在的作用域。
函数作用域可以访问上级作用域(即函数定义时所在的的作用域)可访问的标志符。
重点 :上级作用域,是指函数定义时所在的作用域,与函数在哪执行无关
这句话就涉及到一个嵌套问题,比如上图中的。
如果我说🍏绿色作用域可以访问上级作用域,即🍊橙色作用域可访问的标志符。
那对于🍊橙色作用域,它可访问的标志符就有它本身内部及它的上级作用域,即🍋黄色作用域可访问的
那对于🍋黄色作用域,它可访问的标志符就有它本身内部及它的上级作用域,即🥏全局作用域。
我们完全可以把上面这种嵌套思考成是一个链,比如 🍏 -> 🍊 -> 🍋 -> 🥏
即 函数作用域可以访问该链上后续作用域里的标志符,于是我们把这条链叫做作用域链。
那在上图中就存在三条作用域链
- 🍏 -> 🍊 -> 🍋 -> 🥏
- 🍆 -> 🥏
- 🥚 -> 🚙 -> 🥏
那根据定义,我们自然是知道🍆紫色作用域和 🍋黄色作用域及 🚙青色作用域,三者是独立的,相同点只是他们都可以访问🥏全局作用域。
我们再回到作用域的本质,为了避免标志符的冲突,我们对作用域下的标志符进行了隐藏,那对于作用域链来说,就会涉及到如果后续每个作用域里都有我们需要的标志符的话,我们应该选择哪个标志符呢?
当然是一找到就返回呀,这样就避免了很多混乱的问题。
例子1
// 下面的变量定义在全局作用域(global scope)中
var num1 = 20,
num2 = 3,
name = "Chamahk";
// 本函数定义在全局作用域
function multiply() {
return num1 * num2;
}
multiply(); // 返回 60
// 嵌套函数的例子
function getScore() {
var num1 = 2,
num2 = 3;
function add() {
return name + " scored " + (num1 + num2);
}
return add();
}
getScore(); // 返回 "Chamahk scored 5"
- multiply()执行,multiply函数作用域中没有num1和num2变量,通过作用域链查询到全局作用域下的num1和 num2 ,即20 和 3,所以返回 20 * 3 即 60
- getScore()执行,声明定义好变量和函数后,返回 add(),因为返回需要一个值,所以先执行add函数
- add()执行,返回 name + ' scored ' + (num1 + num2),
- 因为在本身add函数作用域中没有name与num1和num2变量
- 通过作用域链查询,在getScore函数作用域中发现了num1和num2,即2和3,所以关于num1和num2的查询就会停止了
- 但是name在getScore函数作用域中仍未查询到,继续按作用域链向上查询,在全局作用域中发现name变量,即Chamahk。
- 此时,全部变量已经确定,最后把所得值代入表达式中 "Chamahk" + "scored" + (2 + 3),计算后返回 Chamahk scored 5
例子2
这时候我们回到我们在前面抛出的问题
var a = 1;
(function IIFE(def) {
console.log('一'); // 一
var a = 2
console.log(a); // 2
def(a)
})(function def(c) {
console.log('二'); // 二
console.log(a); // ?
console.log(c); // 2
})
// 一 2 二 1 2
这里有点难以理解的是函数表达式作为参数,且直接放在( )里面 。
因为我们知道参数是按值传递的,即( )传递的值是函数表达式的值(地址),所以此时(即在全局作用域下),我们已经定义好了该函数(即函数def),只不过我们并没有把值赋给一个变量,而是直接做为实参传递而已。这意味着,该函数定义的地方是在全局作用域下,所以如果该后续函数被执行了执行,它的上级作用域是全局作用域。
例子3
var n = 10;
function fn() {
var n = 20;
function f() {
n++;
console.log(n);
}
f();
return f;
}
var x = fn();
x();
x();
console.log(n);
这个就当自我练习,我就不写答案了。
内存管理与闭包
像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()。另一方面,JavaScript具有自动垃圾收集机制,也就是说,执行环境会负责管理代码执行过程中使用的内存。
内存生命周期
不管什么程序语言,内存生命周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放\归还
所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像JavaScript这些高级语言中,大部分都是隐含的。
JavaScript 的内存分配
值的初始化
为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。
var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存
var o = {
a: 1,
b: null
}; // 给对象及其包含的值分配内存
// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"];
function f(a){
return a + 2;
} // 给函数(可调用的对象)分配内存
// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
someElement.style.backgroundColor = 'blue';
}, false);
通过函数调用分配内存
有些函数调用结果是分配对象内存:
var d = new Date(); // 分配一个 Date 对象
var e = document.createElement('div'); // 分配一个 DOM 元素
有些方法分配新变量或者新对象:
var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。
var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2);
// 新数组有四个元素,是 a 连接 a2 的结果
栈内存和堆内存
为了区分不同类型值存储的位置,我们又把内存分为以下两种类型:
堆内存:存储引用数据类型值(对象:键值对 函数:代码字符串) 栈内存:提供JS代码执行的环境和存储基本类型值
使用值
使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
当内存不再需要使用时释放
大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“所分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。
高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)
垃圾回收
如上文所述自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。
引用
垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。
在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。
何时销毁
因为这里我们只是为了引进闭包的概念,不对内存管理进行深入的探讨,我们可以简单的认为,当一个对象(包括作用域)不再被引用时,就会自行销毁,释放内存。
函数作用域
一般情况下,当函数执行完成,所形成的私有作用域(栈内存)都会自动释放掉(在栈内存中存储的值也都会释放掉),但是也有特殊不销毁的情况。
我们思考下面三个例子
function MyObject(obj) {
var foo = function () {
console.log('wcdaren');
}
if (!obj) return
obj.method = foo;
}
// 示例1
MyObject()
// 示例2
MyObject(new Object())
// 示例3
var obj = new Object()
MyObject(obj)
示例1
MyObject()被调用,在函数内部有一个匿名函数的实例被创建,并被赋值给 foo 变量,但因为参数 obj 为 undefined,执行 return,所以该函数实例没有被返回到 MyObject()函数外。因此 MyObject()执行结束后,MyObject函数作用域 内的数据未被外部引用,因此MyObject函数作用域销毁,foo 引用指向的匿名函数也被销毁。
示例2
传入参数 obj 是一个有效的对象,于是匿名函数被赋值给obj.method,因此建立了一个引用。在 MyObject()执行结束的时候,该匿名函数与 MyObject()都不能被销毁。但随后,由于传入的对象未被任何变量引用, 因此立即销毁,obj.method 的引用得以释放。这时 foo 指向的匿名函数没有任何引用、MyObject()内部也没有其它数据被引用,因此开始销毁过程
示例3
在示例 3 中开始的过程与示例 2 一致,但由于 obj 是一个在 MyObject()之外具有独立生存周期(不受MyObject函数作用域影响)的外部变量,JavaScript 引擎必须对这种持有 MyObject() 函数作用域中的 foo 变量(所指向的匿名函数实例)的关联关系加以持续地维护,直到该变量被销毁,或它的指定方法 (obj.method)被重置、删除时,它对 foo 的引用才会得以释放。例如:
删除操作不适合于使用var声明的变量,因为var声明的变量不能被delete操作删除
// 1.重新置值时,关联关系被清除
obj.method = new Function()
// 2.删除成员时,关联关系也被清除
delete obj.method
// 3.变量销毁(或重新置值)导致的关联关系清除
obj = null
从上面三个例子中,我们可以得知如果函数执行完成,当前形成的栈内存(作用域)中,某些内容被栈内存(作用域)以外的变量引用了,此时栈内存(作用域)不会销毁。
在不会销毁的作用域里,还有一个全局栈内存只有在页面关闭的时候才会被释放掉,这是显而易见的,因为我们的代码都在该环境执行,只有真正退出了,才会释放此处的内存
闭包定义
这个时候我们再来谈下闭包是什么?
首先,所谓包,指函数与其周围的环境变量捆绑打包;所谓闭,指这些变量是封闭的,只能为该函数所专用。
总的来说,闭包就是一种能保留当初创建时环境变量的函数。在上面的例子3中,即下面这段代码
function MyObject(obj) {
var foo = function () {
console.log('wcdaren');
}
if (!obj) return
obj.method = foo;
console.log(obj);
}
var obj = new Object()
MyObject(obj)
MyObject函数执行后,在该作用域里的环境变量(这里特指里面创建的匿名函数),在函数执行完毕后并没有销毁,所以我们称该函数为一个闭包。
关于闭包,我们还会单独使用一篇文章介绍
递归和函数堆栈
递归
一个函数可以指向并调用自身。有三种方法可以达到这个目的:
- 函数名
- arguments.callee
- 作用域下的一个指向该函数的变量名
在严格模式下,第5版 ECMAScript (ES5) 禁止使用
arguments.callee()。当一个函数必须调用自身的时候, 避免使用 arguments.callee(), 通过要么给函数表达式一个名字,要么使用一个函数声明。
例如,思考一下如下的函数定义:
var foo = function bar() {
// statements go here
};
在这个函数体内,以下的语句是等价的:
bar()arguments.callee()foo()
调用自身的函数我们称之为递归函数。在某种意义上说,递归近似于循环。两者都重复执行相同的代码,并且两者都需要一个终止条件(避免无限循环或者无限递归)。例如以下的循环:
var x = 0;
while (x < 10) { // "x < 10" 是循环条件
// do stuff
x++;
}
可以被转化成一个递归函数和对其的调用:
function loop(x) {
if (x >= 10) // "x >= 10" 是退出条件(等同于 "!(x < 10)")
return;
// do something
loop(x + 1); // 递归调用
}
loop(0);
不过,有些算法并不能简单的用迭代来实现。例如,获取树结构中所有的节点时,使用递归实现要容易得多:
function walkTree(node) {
if (node === null) //
return;
// do something with node
for (var i = 0; i < node.childNodes.length; i++) {
walkTree(node.childNodes[i]);
}
}
跟loop函数相比,这里每个递归调用都产生了更多的递归。
将递归算法转换为非递归算法是可能的,不过逻辑上通常会更加复杂,而且需要使用堆栈。事实上,递归函数就使用了堆栈:函数堆栈。
这种类似堆栈的行为可以在下例中看到:
function foo(i) {
if (i < 0)
return;
console.log('begin:' + i);
foo(i - 1);
console.log('end:' + i);
}
foo(3);
// 输出:
// begin:3
// begin:2
// begin:1
// begin:0
// end:0
// end:1
// end:2
// end:3
预定义函数
JavaScript语言有好些个顶级的内建函数:
**eval()**方法会对一串字符串形式的JavaScript代码字符求值。
**uneval()**方法创建的一个Object的源代码的字符串表示。
**isFinite()**函数判断传入的值是否是有限的数值。 如果需要的话,其参数首先被转换为一个数值。
**isNaN()**函数判断一个值是否是NaN。注意:isNaN函数内部的强制转换规则十分有趣; 另一个可供选择的是ECMAScript 6 中定义Number.isNaN() , 或者使用 typeof来判断数值类型。
parseFloat() 函数解析字符串参数,并返回一个浮点数。
parseInt() 函数解析字符串参数,并返回指定的基数(基础数学中的数制)的整数。
decodeURI() 函数对先前经过encodeURI函数或者其他类似方法编码过的字符串进行解码。
**decodeURIComponent()**方法对先前经过encodeURIComponent函数或者其他类似方法编码过的字符串进行解码。
**encodeURI()**方法通过用以一个,两个,三个或四个转义序列表示字符的UTF-8编码替换统一资源标识符(URI)的某些字符来进行编码(每个字符对应四个转义序列,这四个序列组了两个”替代“字符)。
encodeURIComponent() 方法通过用以一个,两个,三个或四个转义序列表示字符的UTF-8编码替换统一资源标识符(URI)的每个字符来进行编码(每个字符对应四个转义序列,这四个序列组了两个”替代“字符)。
已废弃的 escape() 方法计算生成一个新的字符串,其中的某些字符已被替换为十六进制转义序列。使用 encodeURI或者encodeURIComponent替代本方法。
已废弃的 unescape() 方法计算生成一个新的字符串,其中的十六进制转义序列将被其表示的字符替换。上述的转义序列就像escape里介绍的一样。因为 unescape 已经废弃,建议使用decodeURI()或者decodeURIComponent 替代本方法。