【轻聊前端】JavaScript世界的一等公民函数

发布于 2021-09-07 11:00 ,所属分类:2021面试经验技巧分享

聊完了数据类型、数据存储,当然该聊“封装”了,不知小伙伴们猜到没有呢?

为什么说“封装”,而不是“数据封装”,因为函数封装的更多是行为。

老规矩,从头讲起,逐层深入。

一则逸事

大概7年前,笔者加入了曾经赫赫有名的CDC(用户研究与体验设计部),那时还很青涩,团队为了照顾和我一样的“菜鸟”,开设了一个“小猪快跑”的培训项目,由比较年轻的提一些主题,大佬们来讲解,其中一个主题就是实现“五子棋”。

中间的事不多说,总结一句就是:没写出来(懒,也不知道怎么写)。

到了讲解那天,没法交差,也没有什么处罚,就乖乖听大佬讲解。

具体内容忘了,讲到最后的时候,有人问了一句,你这个是封装好的吧,那试试能不能变成“六子棋、八子棋、十子棋”,然后就真的可以,当时心里大写的佩服。

其实后来再看,也没那么复杂,就是写好一个实现棋盘的方法,然后将格数和规则做成可配置的变量。虽然不复杂,却体现了函数一个很重要的特征——封装、复用。

函数

函数本是数学概念,我们在中学时期都学过,表达的是数据集目标值的一种关系。

JavaScript中的函数类似,表达的是参数和返回值的一种关系。至于是什么关系,由函数内的代码决定。比如下面这样:

functionadd(x,y){
returnx+y
}

就是求两数之和的一个函数。

当然,编程世界中的函数约束更少,可以没有参数,也可以没有返回值。

leta,b;
functioninit(){
a=1
b=2
}

这个函数,调用的时候就不需要传参,也没有返回值,但它改变了全局作用域的数据。

知道了函数的定义和特点,我们什么时候需要用到函数?

第一反应当然是“封装”。

封装什么?“行为”

为什么封装?用一段代码来达到“一个目的”

三个关键词就出来了——封装、行为、一个目的

这三者不是孤立的,它们所产生的效果是相辅相成的。

封装了之后利于维护和复用,目的明确的函数,行为也是明确的,当然,如果再用上一个好的命名,锦上添花,这些都会使得代码的逻辑更清晰,更易读,易测试。

函数家族

上面的段落直奔主题,现在该介绍一下函数的具体表现形式。

命名函数

命名函数已经见过面了,是最常规的定义方式。function关键字加上函数名,后跟圆括号和可选的参数,最后是函数体。

functionadd(x,y){
returnx+y
}

匿名函数

即没有名字的函数,常见于以下几种形式。

函数表达式

letadd=function(a,b){
returna+b
}

事件绑定

letlink=document.getElementById('link')
link.onclick=function(){
//代码
}

立即执行函数

说立即执行,什么是执行呢?前面我们定义了add函数,执行这个函数,或者叫调用,这样就可以 add(x,y)。

立即执行就是在定义的同时调用,像这样:

(function(){…}())
//或者
(function(){…})()

在匿名函数的外面包一层括号,然后加上我们熟悉的调用括号,就成了立即执行函数。

箭头函数

箭头函数是ES6新加入的成员,本身属于和以前定义的函数的不同类型,但它也是匿名函数的一种,就放在这里讨论。

具体来看就是,上面提到的这个函数。

letadd=function(a,b){
returna+b
}

可以这样写:

letadd=(a,b)=>{
returna+b
}
//或者
letadd=(a,b)=>a+b

最直观的感受就是变得简洁了,具体有什么差异,后面’挖一挖‘部分会细说。

回调函数

回调函数按说也可以归为匿名函数,但它和一般函数出现形式不同,就单独说。

举个很常见的例子。

setTimeout(()=>{
console.log('执行我')
},1000)

这段代码的意思是,在 1000ms 之后,执行里面的箭头函数,这里的箭头函数就是回调形式出现。注意这个句式“在什么时候,干什么”,意味着回调函数是有触发条件的,除此之外就是普通函数。

方法

我们通常会叫一个函数是“函数”,不会叫“方法”,但如果一个函数属于某对象,就称为某对象的方法。

当然,这么说并不严谨,即便是定义在全局的函数,也可称为全局的方法,可以用window来调用,这里只讨论通常意义上相对以上几种而言不同的表现形式。譬如:

constpeople={
run(){
console.log('Icanrun')
}
}
people.run()

这段代码中,就称 run 是 people 的一个方法。

好,定义、用途和存在形式介绍差不多了,我们稍微深入一点。

函数本身

介绍完函数的存在形式,函数本身具备哪些属性or方法。

  • name:函数名
  • length:形参个数(不含有默认值的,不含剩余参数)
  • arguments:用于存储实参的对象
  • prototype:toString()和valueOf()等方法的实际保存位置
  • apply()/call():在特定环境调用函数并绑定this
  • bind():创建一个函数的实例,并绑定this

前面的较简单,后面两个我们结合代码示例看一下:

apply()/call()

functionsum(num1,num2){
returnnum1+num2;
}

functioncallSum(num1,num2){
returnsum.call(this,...arguments);
}

functionapplySum(num1,num2){
returnsum.apply(this,[num1,num2]);
}

console.log(callSum(10,10));//20
console.log(applySum(10,10));//20

bind()

constcolor="red";
consto={
color:"blue"
}
functionsayColor(){
console.log(this.color);
}
letobjectSayColor=sayColor.bind(o);
objectSayColor();//blue

这里有个细节提一下,即三种方法均可用于改变this指向,明显的不同在于,call() 和 apply() 是直接调用,bind() 只是创建。

关于函数,相信你已经有个大概的认识,接下来挖掘一些更有意思也更有用的东西。

挖一挖(含高频面试题)

提升

提升这个概念大家并不陌生,即可以在定义的代码行之前使用,不会报错,比如用var定义的变量,只是值可能是undefined。

那么函数也是会提升的,为什么呢,其实函数也是变量,只不过这个变量里存的不是基本类型的值,而是Function。

代码的书写中,利用这个特点,我们可以把业务代码写到文件的头部,函数定义放在底部,这样以来,非必要的情况下,只看逻辑代码即可,只在查看或者修改函数的时候才需要找到相关代码。

比如:

run()
functionrun(){
console.log('Icanrun')
}

但这不是必须的,实际项目也不总是这样,只要知道有这样一种提升的现象。

那么问题来了,这样可以吗?

run2()
varrun2=function(){
console.log('Icanrun')
}

这就不行了,由函数定义,变成了变量定义,run2方法是不存在的。

从而可以看出,函数虽然本质上也是变量,但它和变量声明有区别,变量只“声明”提升,函数是整体提升。

另外还要注意,如果有两个同名函数,后声明会覆盖先声明,而不是相反。

闭包

闭包是个太常谈的话题,网上也有不少争论,我们不去管孰对孰错,争个说法意义不大,更重要的是能够理解到点子上,然后知道它的应用场景和发挥的作用就可以。

闭包的存在形式有多种,简要列几种:

  • 返回函数

这是最直观的

functionsayName(){
varname="hello";
returnfunction(){
returnname;
}
}
varfnc=sayName();
console.log(fnc())//hello

我们就用这种最简单的来说一下闭包是什么现象(均基于ES5):

1、JavaScript有几种作用域?

全局作用域、函数作用域

2、局部变量

函数内使用var定义的变量为局部变量,只能在函数内使用,外部无法访问。

那么上面这段代码有什么不同?

1、定义了局部变量

2、返回值是个函数,且函数中访问了局部变量

3、返回的函数被赋给了外部变量 fnc

得到的结果就是——fnc函数,使用了另一个函数(sayName)中的变量 name,输出了 hello。

这就是闭包的表现,突破作用域的限制,保留住了本该被销毁的上下文环境中的变量

所以为什么有人说函数就是闭包,并不是存在函数的地方都用到闭包,只因为函数是闭包存在的土壤(定义),且是以函数的形式形成(返回)。

再看几种其他常见形式:

一、赋值

varfn1;
functionfn(){
varname="hello";
//函数赋值给fn1
fn1=function(){
returnname;
}
}
fn()
console.log(fn1())

二、传参

functionfn(){
varname="hello";
returnfunction(){
returnname;
}
}
varfn1=fn()
functionfn2(f){
//将函数作为参数传入
console.log(f());
}
fn2(fn1)
//执行输出fn2

三、getter/setter

暴露共有方法,隐藏私有变量。

functionfn(){
varname='hello'//局部私有变量
setName=function(n){
name=n;
}
getName=function(){
returnname;
}

//将setName,getName返回
return{
setName,
getName
}
}
varfn1=fn();
console.log(fn1.getName());//getter
fn1.setName('world');//setter修改闭包里面的name
console.log(fn1.getName());//getter

四、缓存

操作结果缓存,相同参数不需要重复执行。

varfn=(function(){
vararr=[];//用来缓存的数组
returnfunction(val){
if(arr.indexOf(val)==-1){
//缓存中没有则表示需要执行
arr.push(val);//将参数push到缓存数组中
}else{
console.log("此次函数不需要执行");
}
};
})();
fn(10);
fn(10);
fn(100);

关于闭包先说到这,我之前写过另外一篇文章讲闭包,但从示例来讲这里列举了更多,闭包的用途广且强大,还需要大家日常多、多总结,才能更好地理解和掌握。

构造函数

可能很多初学者对这个概念会困惑,有了函数,构造函数又是什么?有什么函数是需要构造的?

不要陷入这个怪圈儿,构造函数和普通函数的用途有本质区别,而这个区别,就是解开困惑的关键。

先看现象。

构造函数在使用的时候有两个特点:

1、new 操作符

2、首字母大写

字母大写仅仅是格式上,关键点在 new 操作符。

functionPeople(name,age){
this.name=name
this.age=age
}
constliming=newPeople('李明',18)
liming//{name:'李明',age:18}

这里的现象就是,定义了一个函数,使用 new 操作符,生成了一个对象。那么 new 的背后发生了什么?

  • 新建一个对象
  • 将对象原型指向构造函数的prototype
  • 将构造函数的this指向创建的对象
  • 如果返回值是基本类型,则返回新创建的对象,否则返回函数中定义的引用类型

正因为经历了这样的过程,就出现了上面的效果。

所以,不需要在字面上去纠结构造函数和函数的关系,第一步就已经点明了它的内涵——用函数来构造一个对象实例

箭头函数和普通函数的区别

ES6之后出现了箭头函数,它看起来就是去掉了 function 关键字,然后在 括号 与 大括号 之间增加了箭头(=>),但实际上差别还挺大的,主要是箭头函数的限制,具体如下:

  • 只能是匿名函数
  • 在声明的地方使用,也就不存在提升的事情
  • 没有用于存储实参的arguments对象
  • 不能用于构造对象实例,即new
  • 没有this绑定,也不能通过bind、apply、call等方法改变this绑定
  • 没有prototype原型对象

主要是这些,其他不常见的先不列。

关于函数的定义和特点,以及使用,就介绍到这,下面聊另一个话题。

函数式编程

经常听到一些大牛会提“编程范式”,什么是编程范式,就是编程时所采用的方式,就跟一个人出行一样,可以开汽车,可以坐轮船,可以坐飞机,都是达到一个目的,只是方式不同。

函数式编程就是编程范式的一种。

先通过一段代码体会一下。

constbeforeList=[1,2,3,4]
letafterList=[]

//写法一
for(leti=0;i<=beforeList.length-1;i++){
if(beforeList[i]>2){
afterList.push(beforeList[i])
}
}

//写法二
afterList=beforeList.filter(item=>{
returnitem>2
})

console.log(afterList)//[3,4]

写法一和写法二会得到同样的结果,但它们的方式有什么不同?

写法二使用了filter()方法,然后由回调函数的返回值决定输出结果。

哦,我明白了,函数式编程就是用函数编程。

单从形式上的确可以这么理解,但它们可归为两类编程:“过程式”和“声明式”。

“过程式”:沿着流程或者步骤走(写法一)。

“声明式”:只表达要做什么,不关心内部实现细节(写法二)。

除此之外,真正的函数式编程还要符合几条标准。

  • 纯函数

什么是“纯”,即基于参数做运算,输出只取决于输入,同样的参数总是返回同样的结果,且不会改变作用域之外的东西。

“不改变作用域之外的东西”有个专有词汇叫“副作用”,通俗理解,一个人感冒了,去买感冒药,吃了两次,感冒症状减轻了,但开始拉肚子,拉肚子就是副作用。

  • 不可变性

数据不可变,怎么理解呢,const?冻结?不能操作?

都不是,是指不直接更改原始数据,而是创建数据的副本,所有操作都使用副本来进行

举个例子。

数组的splice()方法和slice()方法

splice()

constbeforeList=[1,2,3,4]
console.log(beforeList.splice(0,2))
console.log(beforeList.splice(0,2))
console.log(beforeList.splice(0,2))
//[1,2]
//[3,4]
//[]
constbeforeList=[1,2,3,4]
console.log(beforeList.slice(0,2))
console.log(beforeList.slice(0,2))
console.log(beforeList.slice(0,2))
//[1,2]
//[1,2]
//[1,2]

比较可看出,splice() 方法同样的参数在多次调用后输出了不同的结果,不仅不纯了,还改变了原有数据,这就不符合函数式编程的特点,而slice()就能达到理想效果。

  • 高阶函数

函数式编程少不了高阶函数的运用。

什么是高阶函数,将其他函数作为参数传递进行使用,或者将函数作为返回值的函数,就可称为“高阶函数”

实际应用中,可以有如下几种表现:

递归

什么是递归,可以从两个典型场景去理解。

1、几乎一切循环都可以用递归实现。

2、树结构常用递归实现深度遍历。

所以,递归就是反复执行同样的动作,不过数据是在层层递进地变化,直到没有数据需要处理,得出结果

经典的例子:斐波那契数列

1、1、2、3、5、8、13、21、……

functionfibonacci(n){
if(n==1||n==2){
return1
};
returnfibonacci(n-2)+fibonacci(n-1);
}
fibonacci(30)

这是一个原始粗暴版,还有不少优化空间,但用于展现递归的使用是最直接的。

值得注意的是,递归存在一定缺点:时间和空间的消耗比较大、重复计算、栈溢出(可能)。

这些缺点是有办法做优化的,比如:缓存,但JS引擎已经给出一种底层优化方案,叫“尾递归优化”,只是它对代码实现方式是有要求的,只能是在函数执行的最后一步(不一定是最后一行)返回一个函数,而不应做其他操作。表现如下:

//可优化
functionf(x){
returng(x);
}
//不优化
functionf(x){
lety=g(x);
returny;
}
//不优化
functionf(x){
returng(x)+1;
}

Curry(柯里化)

柯里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

语言对于描述这种概念显得苍白且拗口,看一道典型面试题:

sum(2,3)==5;
sum(2)(3)==5;
sum(2,3,4)==9;
sum(2)(3)(4)==9;

乍一看可能不太理解,可以整理一下它的特点:

  • 每次调用都返回一个函数,可连续调用
  • 多次调用存储累加值,并可返回
  • 返回值和目标值之间使用的是 == ,会发生隐式转换,会调用 toString()

如此以来,sum就可以这样写:

functionsum(...args){
//无参数
if(!args.length)return;

//有参数进行累加
functionadd(list){
returnlist.reduce((prev,cur)=>{
returnprev+cur
},0)
}

lettotal=add(args)

//构建闭包,存储累加值
functionk(...args){
total+=add(args)
returnk
}

//重写k的toString方法
k.toString=()=>total
returnk
}

至此,实现累加的柯里化函数就完成了,但实际当中可能不止这一种应用,还会有其他应用,所以参数和方法都是需要能够灵活应变的,那可不可以实现较为通用的curry函数呢?答案是可以。

通用版(ES6):

functioncurry(fn,args){
varlength=fn.length;
varargs=args||[];
returnfunction(){
newArgs=args.concat(Array.prototype.slice.call(arguments));
if(newArgs.length<length){
returncurry.call(this,fn,newArgs);
}else{
returnfn.apply(this,newArgs);
}
}
}

通用版(ES6):

constcurry=(fn,arr=[])=>(...args)=>(
arg=>arg.length===fn.length
?fn(...arg)
:curry(fn,arg)
)([...arr,...args])

Compose(组合)

Compose 跟 Curry 看起来像是近亲,都是用一个函数来封装实现其他函数和参数之间的交互逻辑。

Compose的不同之处在于,它是把逻辑解耦在多个函数中,再通过compose的方式组合起来,将外部数据依次通过各个函数的加工,生成结果。

constadd=num=>num+10
constmultiply=num=>num*2
constfoo=compose(multiply,add)
functioncompose(...funcs){
//funcs被转换为传入方法的数组

//没有传入方法,则返回参数
if(funcs.length===0){
returnarg=>arg
}

//传入一个方法则用一个方法
if(funcs.length===1){
returnfuncs[0]
}

//传入多个方法,则依次调用,返回最终结果
returnfuncs.reduce((a,b)=>(...args)=>a(b(...args)))
}
foo(5)//30

这里的compose所实现的,就是组合了“加法”和“乘法”两种运算,使得参数5经历了两种运算输出最终结果。

简单总结一下,函数式编程的好处有哪些:

  • 易扩展
  • 易模块化
  • 易重用
  • 易测试
  • 易推理

函数式编程由来已久,但近几年才重新引起重视,并在前端领域流行起来,其在流行框架 React 和 Vue 当中都有很多体现,需要好好掌握。

总结

到这里,关于函数的讨论告一段落。

但貌似还漏了一点,就是,为什么说“函数”是一等公民?

我们经常说JavaScript中一切皆“对象”,还常强调原型和继承的重要,不应该“对象”才是一等公民?

其实“对象”大可不必在这里争风吃醋,它当然很重要,但它能做到的事情函数也能做到,但函数具备的它却不一定有,且看函数的一些特点:

  • 作为普通函数,封装、复用
  • 有自己的作用域,且有闭包特性
  • 作为构造函数,构造对象实例
  • 可以以变量的形式传递、调用
  • 作为函数参数
  • 作为函数返回值
  • 函数本身也是对象

鉴于此,说函数是一等公民是实至名归的。

但正因为它具备这么多特性,想用好它,根据不同场景发挥不同威力,并不简单。

有这么几点需要反复练习和琢磨:

  • “拆”与“封”:拆分的粒度和封装的量级
  • 灵活可变:封装不宜太死板,尽量灵活,不然类似的需求还要另外封装,会造成一定代码冗余,复用性也不能充分体现
  • 高阶用法:高阶用法能实现很多强大的效果,事半功倍

啰嗦这么多,依然不能覆盖函数的所有方面,欢迎一起探讨,或者后续有机会再来补充。

猜猜下一篇会是什么呢?~




系列文章:

【轻聊-前端】编程是什么?

【轻聊前端】小角色,大用途——变量

【轻聊前端】为什么说一切皆对象?

【轻聊前端】那些“无理取值”的运算

【轻聊前端】JavaScript中的数字游戏

【轻聊前端】“字符串”江湖

【轻聊前端】有“对象”之前

【轻聊前端】有“对象”之后

【轻聊前端】高级数据结构的基石——数组

现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(十)篇,旨在系统地、逻辑性地把原生JavaScript知识分享给大家,帮助各位较为轻松地理清知识体系,更好地理解和记忆,我尽力而为,望不负期待。

相关资源