本节详细介绍一下函数表达式以及 this 关键字。
实际上,函数本来就是一种数据,在介绍 JS 的数据类型的时候提到过,JS 中引用类型分为对象和函数,所以,函数其实可以用于任何需要数据的地方,这也是 JS 非常灵活的地方所在。
所以实际上,我们可以把 JS 存在一个变量里面,我们也可以把函数作为一个参数进行传递。所以在 JS 中,我们可以说,函数是一等公民。
js
var test = function a(){
console.log(a);
}如上例所示,我将函数赋值给了一个变量保存,此时这个函数就是作为表达式来用的,既然这个函数是一个表达式,那么自然就不会污染全局变量,也不会发生变量提升,所以在 window 对象里并没有 a 这个函数。实际上,在上节课也简要提到过,当函数作为表达式的时候,很多时候其函数名是没有意义的。
js
var test = function a(){
console.log("a");
}
test();//可以通过变量名来调用
a();//报错,函数名无法用来调用函数如上例所示,我们无法在外面使用函数名来调用这个函数,因为如果函数作为表达式之后,函数表达式的目的其实只是创建了一个函数对象,是的,你没有听错,函数其实也是一种对象,只不过这个对象很特别,他可以被执行。所以其实函数表达式就是在创建一个函数对象,在创建函数对象的过程中,这个函数对象的名字其实是没有什么意义的,所以对于函数表达式,我们一般都会使用匿名函数。
js
var test = function (){
console.log("a");
}
test();//此时也可以通过变量名来调用实际上,我们现在就有了两种创建函数的方式,我们做一下区分
js
var a = function(){
console.log(1);
}
function a(){
console.log(1);
}上面两种创建函数的方式,都可以通过 a() 来调用,但二者存在明显区别,首先从概念上来说,第一种实际上是把一个函数表达式赋值给一个变量 a,而第二种则是函数字面量,是正儿八经去创建一个函数,这里从概念上就存在区别。除此之外,二者最大的区别在于变量提升,第二种写法作为函数字面量,是可以整体提升到代码块头部的,但是第一种写法则不行,第一种写法本质上是一个赋值语句,只会将 var a 提升到函数顶部,而赋值语句本身是不会提升到代码块顶部的。
以上两种写法,在内存结构上是一模一样的,函数作为特殊的对象,也是一种应用类型,以上两种写法,都是在内存中的一个特殊区域(我们称之为堆区)创造了一个函数对象,然后在内存中的另一区域(我们称之为栈区)创建了一个变量 a,这个变量 a 里保存一个地址,指向堆区的函数对象。所以调用方式其实也是一样的。
当我们直接写函数名的时候,实际上这个函数名代表的就是函数对象。
js
function a(){
console.log("123");
}
var b = a;//将函数对象a赋值给了b
b();//此时b也可以用于调用这个函数既然 JS 中,函数本质上是一个对象,那他也可以保存在另一个对象内部
js
var a = {
aa:"123",
bb:"234",
cc:function(){
console.log("test");
}
}
a.cc();//a.cc保存的就是一个函数对象,所以此时可以通过这种方式来调用了这个函数
var d = a.cc;//我甚至可以把这个函数赋值给另一个变量
d();//然后通过这个变量,一样可以调用这个函数相信读者可以理解,函数本质上就是一个对象这句话,函数是一种可以被调用执行的对象,在函数这种对象的后面加上一个小括号就可以调用执行它,至少在目前阶段,函数对象和普通对象其实就这一点区别,在后面会学习到一些其他的区别,但不影响函数就是一种特殊对象的本质。
接下来看一个例子
js
function test(fun){
fun();
}
test(function (){
console.log("123");
});可以看到,在上面的例子中,函数 test 的参数在 test 函数内部被调用,我们知道只有函数才会被调用,所以实际上函数 test 接受的参数其实是一个函数对象,所以在调用 test 的时候,我们就传入一个函数对象,在 test 函数执行的时候,再其内部就会调用这个函数。
这种把函数作为参数进行传递,并且在该函数内部进行调用的行为,我们称为回调函数。回调函数是一个非常高级的应用,如果能熟练掌握回调函数,那读者的编程水平一定会上到一个很高的层次。
接下来隆重介绍 this 关键字,this 关键字是一个非常常见的关键字,应用非常广泛。
在全局作用域中,this 关键字固定指向全局对象,在浏览器中,全局对象就是 window 对象。同时,我们无法通过赋值语句给 this 关键字进行赋值。
this 无法赋值语句进行赋值并不意味着 this 是只读的,实际上,在后面我们可以通过其他方式修改 this 。
在函数作用域中,那么谁调用它,那么在内部 this 就指向谁。这里其实可以细分为两种情况,如果是直接调用,this 就直接指向全局对象。如果是通过某个对象的属性进行调用,this 就指向该对象。
js
function a(){
function b(){
console.log(this);//在函数内部输出window
}
b();//由于this是直接调用的,此时会输出全局对象
}
a();
var c = function (){
console.log(this);//输出this
}
c();//此时c是直接调用的,所以输出全局对象
var d = {
e:c,//将函数c作为对象d的一个属性
}
d.e();//此时这个函数是通过这个对象d的属性进行调用的,所以此时输出的就是d综上,我们可以认为,通过谁调用它,this 就会指向谁,如果是直接调用,那么始终指向全局对象。从上面的例子也可以看出,this 的指向并不是固定的,例如函数 c 运行的时候,由于是直接调用,所以指向全局对象,而对象 d 中的函数 e 运行的时候,由于函数 e 是以对象 d 的一个属性进行调用的,所以该函数的 this 就会指向对象 d。我们可以认为,this 最终指向哪一个对象是在调用那一刻决定的。这里和定义域进行区分,JS 使用静态定义域,定义域在函数声明的时候就已经确定,和函数在哪里调用无关,而 this 指向则是在函数调用的时候确定的。
js
var arr = [function () {
console.log(this);
}];
arr[0]();//数组arr
var test = arr[0];
test();//全局对象我们可以用以上例子验证前面的一个结论,即数组本质就是一个对象。可以看到,我将一个函数放在了数组 arr 里,然后通过数组 arr 来调用这个函数,此时输出的就是数组对象 arr 本身,因为数组本身就是一个对象,数组里面的项本身就是数组 arr 的属性。而此时我将这个数组中的函数保存到一个变量,通过访问变量名直接调用,此时就不是通过某个对象调用了,而是直接调用,所以就输出全局对象。
this 有非常多实际用途,以下举例
js
var obj = {
firstName: "Z",
lastName: "YL",
getFullName: function () {
return obj.firstName + obj.lastName;
}
}
console.log(obj.getFullName());通过以上的例子,我们可以看到,其实我们在对象内部,是可以通过对象名来访问到这个对象内的属性的,但是,这是不好的,因为我们这里把对象名写死了,我们往往并不知道对象名是哪一个,如下例所示:
js
var obj = {
firstName: "Z",
lastName: "YL",
getFullName: function () {
return obj.firstName + obj.lastName;
}
}
var obj2 = obj;//用obj2保存obj
obj = undefiend;//obj清空
console.log(obj2.getFullName());//此时直接报错更为理想的方式是通过 this 关键字来完成获取当前对象这一操作
js
var obj = {
firstName: "Z",
lastName: "YL",
getFullName: function () {
return this.firstName + this.lastName;
}
}
var obj2 = obj;//用obj2保存obj
obj = undefiend;//obj清空
console.log(obj2.getFullName());//正常运行前面提到,回调函数非常重要,也有非常多的应用,下面看一个具体实例
js
/**
* 使用自定义比较函数对数组进行排序。
* @param {Array} arr 待排序的数组。
* @param {Function} fn 比较两个元素大小的函数,返回值小于0表示第一个元素小于第二个元素,大于0表示第一个元素大于第二个元素,等于0表示相等。
* @returns {Array} 排序后的数组。
*/
function sort(arr, fn) {
while (true) {
var flag = true; // 初始化为已排序状态
// 遍历数组检查是否已排序
for (var i = 0; i < arr.length - 1; i++) {
// 比较相邻元素
if (fn(arr[i], arr[i + 1]) < 0) {
// 如果前一个元素大于后一个元素,则交换它们
var temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
flag = false; // 标记未排序
}
}
// 如果数组已排序,则结束循环
if (flag) {
return arr;
}
}
}
// 示例使用:排序数组[2, 3, 1, 5, 2, 3],使用默认的升序排序函数
console.log(
sort([2, 3, 1, 5, 2, 3], function (a, b) {
return a - b;
})
);这个案例有必要好好解释,因为涉及到非常多前面没有涉及到的知识点,首先第一个要解释的就是开头的文档注释,对于函数的文档注释可以参考这里的代码,其中参数使用 @param {类型} 形参名 注释 格式进行书写,返回值使用 @ {类型} 注释 格式进行书写。只要按照这种格式进行书写,形如 vscode 等 IDE 就会有很友好的代码提示。
该函数的功能是,用户自定义规则对代码进行排序,而这个规则,就是通过函数 fn 作为参数传递进来的,当我在内部调用这个函数的时候,会给这个函数提供两个参数,即 fn(a,b),若返回值大于 0 等于 0,表示 a 应该在 b 前面,如果返回值小于 0,表示 b 应该在 a 的前面。
接下来就是我们为什么需要这个回调函数,我们知道,我们要实现的功能是,对于所有已给定的数组,不管这个数组是什么结构,我都可以按照某种规则对其进行排序,可以认为是一个万能的排序算法。要实现这个功能,我们很自然地想到一个难点,如何判断哪个在前哪个在后?在以往的排序算法中,我们直接对数字进行比较即可,但如果传入的数组项是一个对象,我要按照对象的某一个属性进行排序,就不能按照前面的算法进行比较了,所以其实我们是无法实现这个功能的,因为我们并不知道用户传递的数组具体是什么结构,也不知道用户要按照哪一种规则进行排序。所以我们应该可以想到,我们可以让用户自己给我们这种规则,我要排序,无非就是要判断两个元素哪个在前哪个在后,我现在函数内部面临的问题无非就是我无法确定这个数组到底是什么结构,也无法确定要按照什么规则进行排序。但是这些东西,使用函数的人一定是知道的,所以我自然就能想到,让使用函数的人给我传递一个方法进来,就用来判断两个元素谁在前谁在后,我在函数内部只需要调用这个函数,传入两个元素,就可以确定谁在前谁在后,从而完成整个排序。
综上,其实回调函数就这么回事,当我函数内部需要使用到一个功能,但是这个功能只有用函数的人一定知道,我写函数的人写不出来,就需要用到回调函数。例如这个例子中,数组结构如何,按照什么规则排序,这都是用函数的人知道的,是我写函数的人不知道的,这就要用到回调函数,让用函数的人把这个排序规则传进来。又例如,我要按照某个规则过滤一些数据,但这个规则我写函数的人是不知道的,但是用函数的人一定知道,这个时候也需要用回调函数。又例如,我要在函数结束之后执行一些操作,但这个操作对我写函数的人来说是不确定的,我只知道在我函数执行之前需要使用这个功能,这个时候也可以让用函数的人传进来,在我函数结束之前进行调用即可。
除了回调函数之外,这个函数还提供了冒泡排序的另一种实现方案,即添加标志位的方案实现冒泡排序。这种算法相较于前面接触过的最原始的冒泡算法在比较次数上有一定的优势,希望读者可以掌握。
实际上,这个 JS 内部有一个 sort 函数,是 JS 写好的函数,我们可以直接使用,其功能和我这里实现的函数是一样的。只不过我这个是简单的实现,实际的 sort 函数更加复杂,会考虑更多的情况,也会使用更加优秀的排序算法。关于 JS 中内置的 sort 算法,可以参考:Array.prototype.sort() - JavaScript | MDN