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

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

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

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

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

一则逸事

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

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

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

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

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

函数

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

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

1
2
3
function add(x,y){
return x + y
}

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

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

1
2
3
4
5
let a,b;
function init(){
a = 1
b = 2
}

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

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

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

封装什么?“行为”

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

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

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

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

函数家族

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

命名函数

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

1
2
3
function add(x,y){
return x + y
}

匿名函数

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

函数表达式

1
2
3
let add = function(a,b){
return a + b
}

事件绑定

1
2
3
4
let link = document.getElementById('link')
link.onclick = function(){
//代码
}

立即执行函数

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

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

1
2
3
( function (){…} () )
//或者
( function(){…} )()

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

箭头函数

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

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

1
2
3
let add = function(a,b){
return a + b
}

可以这样写:

1
2
3
4
5
let add = (a,b) => {
return a + b
}
// 或者
let add = (a,b) => a + b

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

回调函数

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

举个很常见的例子。

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

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

方法

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

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

1
2
3
4
5
6
const people = {
run(){
console.log('I can run')
}
}
people.run()

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

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

函数本身

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

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

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

apply()/call()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function sum(num1, num2) {
return num1 + num2;
}

function callSum(num1, num2) {
return sum.call(this, ...arguments);
}

function applySum(num1, num2){
return sum.apply(this, [num1, num2]);
}

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

bind()

1
2
3
4
5
6
7
8
9
const color = "red";
const o = {
color: "blue"
}
function sayColor(){
console.log(this.color);
}
let objectSayColor = sayColor.bind(o);
objectSayColor(); //blue

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

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

挖一挖(含高频面试题)

提升

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

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

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

比如:

1
2
3
4
run()
function run(){
console.log('I can run')
}

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

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

1
2
3
4
run2()
var run2 = function(){
console.log('I can run')
}

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

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

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

闭包

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

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

  • 返回函数

这是最直观的

1
2
3
4
5
6
7
8
function sayName(){
var name="hello";
return function(){
return name;
}
}
var fnc = sayName();
console.log(fnc())//hello

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

1、JavaScript有几种作用域?

全局作用域、函数作用域

2、局部变量

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

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

1、定义了局部变量
2、返回值是个函数,且函数中访问了局部变量
3、返回的函数被赋给了外部变量 fnc

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

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

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

再看几种其他常见形式:

一、赋值

1
2
3
4
5
6
7
8
9
10
var fn1;
function fn(){
    var name="hello";
    //函数赋值给fn1
    fn1 = function(){
        return name;
    }
}
fn()
console.log(fn1())

二、传参

1
2
3
4
5
6
7
8
9
10
11
12
13
function fn(){
    var name="hello";
    return function(){
        return name;
    }
}
var fn1 = fn()
function fn2(f){
    //将函数作为参数传入
    console.log(f());
}
fn2(fn1)
//执行输出fn2

三、getter/setter

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function fn(){
     var name='hello' //局部私有变量
     setName=function(n){
        name = n;
     }
     getName=function(){
        return name;
     }
         
   //将setName,getName返回
     return {
         setName,
         getName
     }
 }
 var fn1 = fn();
 console.log(fn1.getName());//getter
 fn1.setName('world');//setter修改闭包里面的name
 console.log(fn1.getName());//getter

四、缓存

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var fn = (function () {
var arr = []; //用来缓存的数组
return function (val) {
if (arr.indexOf(val) == -1) {
//缓存中没有则表示需要执行
arr.push(val); //将参数push到缓存数组中
} else {
console.log("此次函数不需要执行");
}
};
})();
fn(10);
fn(10);
fn(100);

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

构造函数

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

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

先看现象。

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

1、new 操作符
2、首字母大写

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

1
2
3
4
5
6
function People(name,age){
this.name = name
this.age = age
}
const liming = new People('李明',18)
liming // { name: '李明', age: 18 }

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

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

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

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

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

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

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

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

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

函数式编程

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const beforeList = [1,2,3,4]
let afterList = []

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

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

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

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

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

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

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

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

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

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

  • 纯函数

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

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

  • 不可变性

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

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

举个例子。

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

splice()

1
2
3
4
5
6
7
const beforeList = [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 ]
//[]
1
2
3
4
5
6
7
const beforeList = [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、……

1
2
3
4
5
6
7
function fibonacci(n) {
if (n == 1 || n == 2) {
return 1
};
return fibonacci(n - 2) + fibonacci(n - 1);
}
fibonacci(30)

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
//可优化
function f(x){
return g(x);
}
// 不优化
function f(x){
let y = g(x);
return y;
}
// 不优化
function f(x){
return g(x) + 1;
}

Curry(柯里化)

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function sum (...args) {
// 无参数
if (!args.length) return;

//有参数进行累加
function add (list) {
return list.reduce((prev, cur) => {
return prev + cur
}, 0)
}

let total = add(args)

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

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

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

通用版(ES6):

1
2
3
4
5
6
7
8
9
10
11
12
function curry(fn, args) {
var length = fn.length;
var args = args || [];
return function(){
newArgs = args.concat(Array.prototype.slice.call(arguments));
if (newArgs.length < length) {
return curry.call(this,fn,newArgs);
}else{
return fn.apply(this,newArgs);
}
}
}

通用版(ES6):

1
2
3
4
5
const curry = (fn, arr = []) => (...args) => (
arg => arg.length === fn.length
? fn(...arg)
: curry(fn, arg)
)([...arr, ...args])

Compose(组合)

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const add = num => num  + 10
const multiply = num => num * 2
const foo = compose(multiply, add)
function compose(...funcs) {
// funcs被转换为传入方法的数组

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

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

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

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

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

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

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

总结

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

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

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

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

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

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

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

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

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

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

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