第三章 函数
背景简介
JavaScript中函数有两个主要特点使其变得特殊:
- 函数是“第一类对象”,更久之前也称之为“第一等公民”
- 函数可以提供作用域
什么是第一类对象
第一类对象(First-class object)这个名称可以追溯到1960年,原称为第一类公民(First-class citizen),简单点来总结来说,可以在该语言中做到其他元素都能进行的所有操作(你们能干的我也都能干),就可以称为一等公民。
一等公民的概念在《计算机程序的构造和解释》书籍中提及,而我们JavaScript的一等公民的概念是因为Brendan Eich(布兰登·艾奇 - JS之父)在设计语言是借鉴了Scheme语言,在Scheme中就函数就是一等公民,而我们js也是借鉴了这个,所以才会出现这么一个名词。
它包含了一些特性,其特征表现如下:
- 可以赋值给变量
- 可以作为参数传递
- 可以作为结果抛出
- 可以包含在数据结构里
当然还有更多不同的特征解释,不做太多介绍,了解即可。
函数可以提供作用域
在ES5时,{}
花括号并不能提供局部作用域,也就是说,块并不创建作用域,而在函数内定义的任何变量都是局部变量,外部无法直接访问。
考虑到块并不产生作用域,所以在if、while、for
中,使用var
创建的变量并不意味着它是局部变量,如果没有函数包裹,它将成为一个全局变量,而保证一个干净的全局变量是一个好习惯,所以在涉及变量作用域时,函数就成了必不可少的工具。
当然,这个问题在ES6时得到改善,我们可以通过const、let
在块级中声明,也可以产生局部作用域变量。
函数声明的术语
函数有三种声明方式:
- 函数声明
- 函数表达式
- 构造函数
函数声明
function test() {
}
函数表达式
表达式分为两种,一种是命名函数表达式,另一种是匿名函数表达式(一般称为函数表达式)。
//命名函数表达式
var test = function test() {};
//匿名函数表达式
var box = function() {};
区别就在于函数是否拥有名称!
我们需要知道函数对象拥有一个name
属性,它保存着函数声明的名称,而匿名函数的name
属性会是一个空字符串,函数的name属性在bug调试中能有很大的帮助,因为浏览器会直接输出该函数的name,以便于开发者快速定位问题的地点。
当然使用上都是一样的。
从技术的角度讲,命名函数表达式的变量名和函数名不一定要相同:
var test = function yest() {};
test.name; //"yest"
但是在一些浏览器上,比如ie浏览器,这种行为没有被正确的实现,所以不推荐使用不同命名。
构造函数
var test = new Function("a","b","return a + b;");
Function构造函数创建函数时并不会创建当前环境的闭包,它们总是被创建于全局环境,因此在运行时它们只能访问全局变量和自己的局部变量,不能访问它们被 Function
构造函数创建时所在的作用域的变量。这一点与使用 eval()
执行创建函数的代码不同。
函数的提升
上述中还有一点并没有提到,那就是函数的提升,函数提升与变量提升并不相同,以下面代码为例:
function a() {
console.log("global a");
};
function b() {
console.log("global b");
};
function c() {
console.log(typeof a); //function
console.log(typeof b); //undefined
a(); //local a
b(); //TypeError b is not a function
function a() {
console.log("local a");
}
var b = function() {
console.log("local b");
}
};
c();
从代码中我们可以看到,函数命名声明的函数如同变量提升一样,会自动提升到函数作用域的顶部,而变量命名只是将变量名提升到顶部,并没有进行赋值操作。这一点很重要。
如果函数命与变量名相同,那么一定是变量声明先提升,然后才是函数提升。
了解到这里,我们对于函数的一些术语理解没啥问题了。
回调模式
回调模式是函数作为一等公民的一种体现,我们可以将函数作为参数传递给另一个函数,或者将函数存储在对象中,将对象作为参数传递给函数。
function a() {}
function b(callback) {
//执行一些逻辑
callback();
}
回调一般用于一些异步操作,或者是一些复杂的处理工作,举个例子:
我们需要处理一个10万dom的任务,由于处理的过程繁琐,我们可能会将工作拆为2个部分:
- 获取dom,比如筛选操作
- 对dom进行业务操作
function getDom() {
//模拟一个复杂的循环,不要考虑真实意图
var i = 100000,
arr = [];
while(i) {
arr.push(i);
i--;
}
return arr;
};
function domHide(nodeArr) {
var i = 0,
len = nodeArr.length;
for(; i < len; i++) {
nodeArr[i].style.display = "none";
}
}
//执行
domHide(getDom());
我们查看代码发现,虽然逻辑被拆分了,函数功能更加单一了,但是这个实现是非常低效的,我们必须进行两次10万级遍历,这显然并不是高效的方式。
我们可以通过回调的方式,将操作函数交给getDom
去操作,从而减少一次10万级遍历。
function getDom(callback) {
//模拟一个复杂的循环,不要考虑真实意图
var i = 100000,
arr = [];
if(typeof callback !== "function") {
callback = false;
}
while(i) {
arr.push(i);
//运行回调
if(callback) {
callback(i);
}
i--;
}
return arr;
};
function domHide(dom) {
dom.style.display = "none";
}
//执行
getDom(domHide);
这是一种很直接的实现方式,其中可能会认为domHide
与getDom
耦合了,但是其实并没有,callback可以是任意函数,而且还是可选的,不存在的话是不会运行的,且最终也会返回arr,那么即便不使用回调,也不会影响到原始使用。
回调的this指向
虽然上面那种做法简单有效,但是有时候回调函数并不是一个直接函数或者匿名函数,而是对象的方法,如果该方法内存在this
的使用,这就可能会出现问题。
var obj = {
name: "obj",
run: function() {
console.log(this.name);
}
};
function test(callback) {
if(typeof callback === "function") {
callback();
}
};
test(obj.run); //得到一个空,因为此时this指向window
//如果使用严格模式,你将得到 Uncaught TypeError: this is undefined 的错误提示
显然这并不是我们期望的,传统的做法就是将obj
也作为参数传入,而回调不直接传入,而是传一个“key”。
function test(callback, target) {
if(typeof callback === "string") {
callback = target[callback];
}
if(typeof callback === "function") {
callback();
}
};
test("run",obj); //obj
当然我们也可以使用call、apply、bind
方式去改变this的指向。
异步事件处理
回调模式的另一种用途就是异步事件处理,当我们给页面的元素添加一个事件的时候,实际上就是提供了一个回调函数;这个函数会在事件触发时调用。
document.body.addEventListener("click", function() {}, false);
举个异步事件的现实例子,当你作为一个面试官时,每天面试100人,那么面试结果怎么处理,如果让面试者自己电话来联系你询问结果,那么你可能整天都处于接电话的状态,怎么去解决这个问题,我们可以在面试时告诉面试者:“面试通过我会电话通知你的!”;
此时就进入了一个异步事件,如果他没有通过,那么它的电话永远不会触发,那么对于这部分的精力就可以做到节省。
异步事件处理也是同理,当事件没有被触发时,这个函数将不会被调用。
超时
回调模式的另一个应用就是超时处理,比如:setTimeout、setInterval
;我们传递一个回调函数,这个回调会在设定的时间被触发。
需要注意的是你设置的时间,回调触发时不一定是完全按照设定值的延迟,这个参考JS事件队列;不做过多赘述。
function a() {};
setTimeout(a,1000);
库中的回调模式
当设计一个库时,库的代码应该尽可能的通用和可复用,而回调就可以帮助实现这种通用化,因为我们不需要考虑具体的功能逻辑实现,我们只需要实现某一个功能,剩下的就交给回调来处理,这样就不用考虑到每一种的可能,从而导致库的极速膨胀,并产生大量不会被大多数人使用到的功能代码。
通过钩子的回调参数形式,这将帮助我们更容易的构建、扩展、以及自定义库的方法。
返回函数
函数也是对象,因此它可以作为返回值来使用。这表示一个函数并不需要返回一个数据值或者数据数组作为其执行的结果返回,函数可以返回另一个函数来做更高阶的处理,也可以按需进行创建需要的函数。
function a() {
//处理逻辑
return function() {
//再处理逻辑
}
}
//使用
var b = a();
b();
由于函数存在作用域,所以return出来的函数可以访问到上一个函数的作用域,于是乎这种方式可以用来做一些私有数据处理。
function a() {
var count = 0;
return function() {
return count += 1;
}
}
//使用
var next = a();
next(); //1
next(); //2
next(); //3
函数覆盖
如果函数名相同,新的函数将覆盖旧的函数,某种程度上,是变量的旧函数引用地址被覆盖指向了新的函数地址。而这一切还可以发生在函数体内。
function test() {
console.log(1);
test = function() {
console.log(2);
};
}
test(); //1
test(); //2
这种做法,常常应用于减少一些不必要的重复工作,比如一些环境的判断,只需要在首次判断就可以了,不需要每次运行都进行判断,从而提升我们的程序性能。
这种方式也被称为惰性函数!
这种模式当然也存在缺点,就是重新覆盖后,之前给原始函数添加的任何属性都会丢失,如果该函数分配给一个变量或者对象的属性使用,那么因为实际存储的是函数引用地址,每次调用时还是会调用原始函数,而不是被覆盖的新函数。
function test() {
console.log(test.age);
test = function() {
console.log(test.age);
};
}
test.age = 16;
var a = test;
var b = {
run: test,
};
a(); //16
b.run(); //undefined
test(); //undefined
运行a
时,调用的是原始test函数,此时可以正常输出我们给test设置的属性age,输出后test这个变量所存储的引用地址被覆盖为新的test函数引用地址。
此时我们再运行b.run
,它依旧保存着原始函数的引用地址,所以它运行的还是原始函数,但是因为原始函数使用了test
变量,但是test的地址依旧变更了,所以拿的是新函数的age,新函数没有age属性,于是返回undefined
。
再次运行test
函数时,运行的是新的test函数,也不存在age属性,所以也是undefined
。
即时函数
即时函数也就是自运行函数,是一种在定义函数后立即执行该函数的语法。
(function() {
alert(1);
})();
这种模式本质上不管函数是匿名还是命名的,都可以在创建后立即执行,它除了上面这种写法,还有另外两种写法。
function() {
alert(2);
}();
(function() {
alert(3);
}());
一般大家都是用的开头那种即时函数。
这种模式是非常有用的,因为它除了提供了作用域以外,还在在页面加载时,自动去执行一些任务,比如配置的初始化,额外的补丁处理(Polyfill),第三库的全局抛出以及插件的自动挂载。
我们可以不必担心在里面创建的变量会泄露到全局去。
参数
即时函数也是支持传参的,在后面的括号里我们可以自定义传入的参数,一般常见的参数就是传入全局对象。
(function(global){
//处理
global.test = 1;
})(this);
这种做法可以帮助我们在不使用window作为挂载对象时的处理,如果写死了window,那么这段函数只能再window上挂载数据,使用参数,我们可以指定任何对象挂载数据。
这有利于我们的代码在不同的环境中的运行。
返回值
即时函数也是有返回值的,这些返回值也可以分配给变量使用。
var a = (function(global){
//处理
global.test = 1;
return true;
})(this);
console.log(a); //true
优点和用法
即时函数可以帮助我们包装很多需要执行的工作,且因为作用域的存在,不会在全局留下糟糕数据。
这种做法也常用于模块的封装,比如JQ模块,UMD模块等等。
即时对象初始化
保护全局作用域不受污染的另一种方法,就是即时对象初始化,类似于即时函数,但是这种模式是通过一个带init
方法的对象,该方法会在创建对象后立即执行。
({
name: "obj",
age: 16,
getBoy: function() {
return this.name + this.age;
},
//初始化
init: function() {
console.log(this.getBoy());
//更多操作
}
}).init();
字面量对象加括号是因为js的语法解析上,如果直接对字面量进行调用函数,会出现错误,所以需要加括号,这个括号有两种用法:
({...}).init();
({...}.init());
使用这种方式的缺点在于大多数的代码压缩工具无法缩短对象的属性和方法名,只能进行一些换行和空格的去除,个人测试babel确实没有进行混淆属性名。
初始化时分支
这是一种优化模式,当某个条件在程序生命周期内不会发生改变的时候,仅对它做一次判断,是很有意义的,比如嗅探浏览器是否支持某个api。
var addEvent;
if(typeof window.addEventListener === "function") {
addEvent = window.addEventListener;
} else if (typeof document.attachEvent === "function") {
addEvent = function(el,type,fn) {
el.attachEvent("on" + type, fn);
};
} else {
addEvent = function(el,type,fn) {
el["on" + type] = fn;
}
}
通常情况下,浏览器的特性都是独立更新的,所以不要假设浏览器支持addEventListener
就一定支持XMLHttpRequest
,最好单独嗅探判断。
函数属性-备忘模式
函数是对象,因此他可以添加属性和方法,例如每一个函数,无论使用什么方式创建,它都会自动获得一个length
属性,通过length属性可以知道函数期望的参数数量。
function test(a,b) {};
test.lengt; //2
需要注意的是,如果是使用了...
语法来接受参数,参数被视为可选参数,length是不做计数的。
我们可以在任何时候将自定义属性添加到函数中,其中一个常见的做法就是缓存计算结果,以免在下一次调用时重复做复杂的计算。
function test() {
if(!test) {
//计算复杂的结果,伪代码
var result = {};
test.cache = result;
}
return test.cache;
}
配置对象
当一个函数的参数过多时,往往会产生很多问题,这些问题会随着业务逻辑的增加变得越来越痛苦。
比如:
function test(a,b,c,d,e) {
...
}
test函数有5个参数,在目前在业务上,其中e
是可选参数,其他都是必填参数。
有一天,我们需要利用test实现一个新的逻辑,在改动完函数内的代码时,我们发现调用时e
成了必填参数,d
成了可选参数,但是此时我们已经没法控制参数的位置了。
于是不得不这么调用:
test(1,2,3,null,5);
这显然是一个非常痛苦的方式。
我们可以通过传入一个配置对象的方式来处理多个参数的问题。
var config = {
a: 1,
b: 2,
c: 3,
d: 4,
e: 5
}
function test(config) {
...
}
这对于维护来说更加方便,也不用考虑参数的顺序。
日常例子:
当我们再创建dom的时候,可能需要传入大量的css来进行控制该dom,此时我们可以将css的配置做成配置对象的形式,统一传入创建dom的函数中,方便使用和维护。
柯里化(Curry)
柯里化是函数的高阶技术,除了在JavaScript中应用外,还广泛应用在其他语言上。
柯里化是函数式编程中的一种过程,可以将接受具有多个参数的函数转化为一个的嵌套函数队列,然后返回一个新的函数以及期望下一个的内联参数。它不断返回一个新函数(期望当前参数,就像我们之前说的那样)直到所有参数都用完为止。这些参数会一直保持“存活”不会被销毁(利用闭包的特性)以及当柯里化链中最后的函数返回并执行时,所有参数都用于执行。
简单点来说,比如我有一个函数,函数有20个参数,其中8个参数可能每次调用函数时都是相同的,我们有必要每次都传入相同参数吗?显然没有这个必要,我们可以节省这部分开支。
那么我们就在第一次函数运行时先将这个8个参数接收并保存,通过函数作用域的形式存储在局部作用域中,然后再return出一个新的函数用于接收其他参数,如果有需要,我们还可以每次运行都重复return新的函数来接收参数,直到参数全部用完为止。
来个简单代码例子:
function test(a,b,c,d,e,f,g) {};
//使用
test(1,2,3,4,5,7,8);
test(1,2,3,7,8,9,10);
test(1,2,3,4,6,9,4);
//可以发现我们总是会有一些重复的传入的参数
//优化
function test(a,b,c) {
const cache = [a,b,c];
return function(d,e,f,g) {
...操作
}
}
var curry = test(1,2,3);
//使用
curry(4,5,7,8);
curry(7,8,9,10);
curry(4,6,9,4);
如果有必要我们还可以增加柯里化的层数。
了解到它的基本写法,我们日常中有哪些地方可以使用呢?
- 参数复用,重复参数可以提前存起来
- 延迟执行,返回新函数,等待执行
- 返回正确的可执行函数,比如环境嗅探
- 等等...
以上面初始化分支的代码为例,我们可以改为柯里化模式
var addEvent = (function() {
if(typeof window.addEventListener === "function") {
return window.addEventListener;
} else if (typeof document.attachEvent === "function") {
return function(el,type,fn) {
el.attachEvent("on" + type, fn);
};
} else {
return function(el,type,fn) {
el["on" + type] = fn;
}
}
})();
通用的一个柯里化格式函数
function schonfinkelize(fn) {
var slice = Array.prototype.slice,
args = slice.call(arguments,1);
return function() {
var newArgs = slice.call(arguments).concat(args);
return fn.apply(null,args);
}
}
通用处理了两层。
lodash的防抖也是柯里化
lodash
库的防抖动函数_debounce
其实也利用了柯里化,当我们使用_debounce
防抖动之后,其实会返回一个新的函数,这个函数为cancel
,用于取消对函数的延迟调用。
var test = function test() {}
test = _.debounce(test,1000);
//不需要防抖了可以直接取消,取消后再调用test就和普通函数一样,没有防抖功能了
test.cancel();
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据