说说JavaScript中First-Class Function

590 阅读14分钟

first-class.jpeg

1. 前言

JavaScript从本身特点来看,它是一门“轻”语言。最直观的感受就是从开发角度来说,它给予我们最大的便捷和灵活。

  1. 直给。比如,打印消息直接使用console.log('hello, world.')或者alert(xxx)
  2. Ducking Type。弱类型语言的特色。通过数据推导出变量的数据类型,大部分时候会让开发者编写更少的代码。当然也有自身的一些问题,这里不再详细叙述。
  3. 函数式编程。在JavaScript中,函数是“头等公民”,虽然面向对象思想更高级,但是在JavaScript中,函数可以带来更好的性能优势(相比于去实例化对象)。

2. 函数

这篇文章主要说一下,JavaScript中的函数。

2.1 为什么要单独说函数?

诚如标标题中所言,在JavaScript中函数Functions 是 First-Class。这句话的意思就是说,函数是头等公民,而First-Class Function就是头等函数的意思。

所谓的头等函数(first-class function)是指在程序设计语言中,函数被当作头等公民。这意味着,函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中。

有过JavaScript基础的伙伴们,会发现学好函数基本就将大半部分JavaScript学通了。因此这里给大家详细的函数相关介绍。

2.2 什么是函数?

通常我们听过最多的说法是这样的:“函数是一段可以被反复调用的代码块。”这是相对来说最容易被人接受的说法,当然对前端新人也更加友好。

因为在实际开发中,函数就是用来封装一段逻辑的实现,然后将多个函数组合起来调用,形成复杂的业务场景。

但是从语法或者规范上定义的话,其实函数就是Function构造器的实例对象。这里就可以看出其实函数在JavaScript中就是比较特殊的对象类型之一。

因此,函数可以有自己的属性和方法,也有自己的继承关系(原型链)。

说到这里,可能有的伙伴们就会有这么一个疑问?函数内部是如何存储代码和相关属性的呢? 大家不要着急,后面会有解答。

2.3 函数定义

这一小节,我们先来看看如何去定义函数。通常见到的就是以下2种:

  • 声明式
  • 表达式
// 声明式 定义函数
function test(){}
// 表达式 定义函数
var fn = function (){}

2.3.1 声明式

基本语法:

function <BindingIdentififier>(<FormalParameters>){
    <FunctionBody>
}

函数声明式定义,通过关键字function后接函数名称(即绑定的标识符名称),然后在一对圆括号内定义函数的形式参数,最后在一对大括号内书写函数体部分。

2.3.2 表达式

var <BindingIdentififier> = function [<Identififier>](<FormalParameters>){
    <Function
}

函数表达式定义,语法上与声明式很是相似。通过var定义一个变量后接赋值运算符=,接着function关键字后边是可选的函数名字,然后在一对圆括号内定义形式参数,最后在一对大括号内书写函数体部分。

2.3.3 小结

无论使用哪种定义方式去创建一个函数,其实本质上函数都是由以下几部分组成:

  • 关键字 function
  • 函数名字
  • 形参:定义的形式参数
  • 函数体:函数里所书写的那些语句,这里也包括return语句,用来指定函数的返回值

在JavaScript中,有一种叫做“callable”的数据类型,函数就是其一,因此可以通过运算符()去执行函数的函数体部分语句。这个过程被称为“函数调用”。

fn() // 调用fn函数
test() // 调用test函数

2.3.4 其他定义

除了声明式和表达式之外,我们还可以通过Function去创建函数以及ES6中的箭头函数。

var fn = new Function() // Function构造器
var test = () => {} // 箭头函数

语法上: new Function(p1, ..., pN, body)

  • p1 到 pN 为形参
  • 最后body为函数体
  • 如果new Function时没有传入任何参数,那么相当于创建的函数无参,函数体为空语句。
  • 如果new Function时仅传入一个参数,此时为body部分
  • 否则,除了最后一个参数为body部分,其他都是定义的函数形参
  • new Function时所有传入的参数都是字符串类型

这种定义方式是有特定用途的,也不是一无是处。比如说用来在非严格模式下替代eval

语法上:(<FormalParameters>) => {<FunctionBody>}

  • ()语法格式,内部定义函数的形参。如果仅有一个参数时,可以省略。

  • =>箭头也是语法格式,也是为啥叫箭头函数的原因。

  • {}语法格式,代表函数体部分。

    • 如果函数体仅有一个表达式,此时表达式的值为函数的返回值。
    • 如果函数体仅有一条函数语句,那么大括号也可以省略。

目前,声明式和箭头函数是应用最多的两种函数定义方式。

3. 函数本质

虽然在JavaScript中,对一个函数使用typeof运算后得到的是一个'function'的字符串返回值。似乎告诉我们函数是一个拥有自己类型的数据。其实这是为了和其他对象区分开来,本质上,函数就是一个Callable Object。也就是说,函数就是对象。

那么,函数对象是怎么存储的呢?

首先,说一下实例化函数对象过程:以声明式为例

  • 定义name变量,并设置值为BindingIdentififier的字符串值
  • 定义sourceText变量,值为与函数声明匹配的源文本
  • 定义F变量,值为OrdinaryFunctionCreate(%Function.prototype%, sourceText, FormalParameters, FunctionBody, non-lexical-this, scope)
  • 执行SetFunctionName(F,name)。
  • 执行MakeConstructor(F)。
  • 返回F。

接着,我们主要看一下OrdinaryFunctionCreate的主要执行过程: OrdinaryFunctionCreate ( functionPrototype, sourceText, ParameterList, Body,thisMode, Scope )

  1. 这里断言Type(functionPrototype)为Object
  2. 定义F变量,值为OrdinaryObjectCreate(functionPrototype, internalSlotsList).
  3. 设置F.[[ECMAScriptCode]]值为Body
  4. 设置F.[[FormalParameters]]值为FormalParameters
  5. 定义变量 Strict。如果Body中为严格模式的代码,Strict 为 true;否则 false。
  6. 设置F.[[Strict]]值为Strict.
  7. 如果参数thisMode为lexical-this,将F.[[ThisModel]]设置为 lexical。
  8. 否则如果 Strict 为 true,设置F.[[ThisModel]]为 strict。
  9. 否则,设置F.[[ThisModel]]为global
  10. ...
  11. 返回F

ok!现在是不是感觉有点摸清函数了吧。简单来说,当函数被调用时,将函数对象的内部属性[[ECMAScriptCode]]的值放到JS引擎内部执行。而当fn.name时,直接读取name属性值即可,无需额外的处理。

4. 函数的妙处

掌握函数的基本概念之后,接下来我们一起体验函数的妙用的。

4.1 封装与复用

这是函数的基本作用

比如,像这样的:

function createRequest(){
    return window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject('Microsoft.XMLHTTP')
}

上面例子,是封装了一个创建网络请求对象的工厂函数,是一种设计模式。同样也体现出封装与复用的好处。

封装 的意义就在于使用者只关注函数是如何调用的,返回值是什么;不需要关心内部如何实现的,比如例子中是如何实现兼容性的。

复用 的意义在于开发者可以提前将一些工具类的函数实现,然后使用者都去引用这些工具即可,而无需重复定义,造成代码冗余的问题。

4.2 高阶函数

所谓高阶函数就是指那些接收其他函数为参数或返回另一个函数的函数

  1. 接收函数为参数
function each(arr, callback){
    if(arr instanceof Array === false){
        return console.error('arr is not a array.')
    }
    
    var i = 0;
    var l = arr.length;
    
    for(; i < l; i++){
        if(callback(arr[i], i, arr) === false){
            break;
        }
    }
}

上面例子是JQuery中each方法的简单实现。

如果我们在封装一些工具类的函数,具体实现不是首要考虑的,我们最应该优先考虑其扩展性和适用性。比如JQuery中的each方法,是遍历数组和伪数组的工具函数,为了提高适用性和扩展性,each除了接收待遍历的对象之外,还接收一个函数为参数,当遍历到每一个值时,会调用该函数,将值和对应索引等数据传入进去。each的使用者就可以在外部定义好该函数,从而完成遍历时逻辑设计。

诸如这类的工具在ES5后添加了好多,比如forEach,map,filter,reudce,...

  1. 返回另一个函数 虽然JavaScript自身是一门弱类型语言,但是在实际开发中,我们需要更加不同的数据类型实现不同业务逻辑,因此,很多时候都需要知道数据的准确类型。

像这样,

let isString = obj => Object.prototype.toString.call( obj ) === '[object String]';

let isObject = obj => Object.prototype.toString.call( obj ) === '[object Object]';

let isNumber = obj => Object.prototype.toString.call( obj ) === '[object Number]';
let isFunction = obj => typeof obj === 'function';
let isArray = obj => obj instanceof Array;

上述代码中,虽然足够满足我们开发需要,但是显然代码不够优雅,还是有冗余的。不妨考虑封装一个工具类函数用于判断类型呢。

像这样,

function isType(type){
    return function(obj){
        return Object.prototype.toStirng.call(obj) === `[object ${type}]`;
    }
}
// test
isType('String')('123');       // true
isType('Array')([1, 2, 3]);    // true
isType('Number')(123);         // true
isType('Function')(() => {});  // true

上面代码相信大家都能看懂,但是可能会有所疑问:为什么isType函数不定义成两个参数,这样就不会出现两个(),让人感觉很别扭呢?

相信大家都听说过函数式编程,更多的理解就是组合函数完成复杂的业务,但是很少人会告诉你如何去实现这些个函数。因此呢,大家都是根据的自己的理解和习惯去实现这些函数,从而导致这所谓的函数式编程就像是古代妇女的裹脚布又臭又长(难以理解难以阅读)。

其实要想优雅的实现每一个函数,你要考虑的是如何使这些函数变得简单易用即可。

比如这样:

function isType(type, obj){}

isType若是两个参数,那么你就需要事先确定参数位置以及对应含义,同时使用者也要参考文档并记住。这就无形中增加了这些工具函数的学习成本,长久以往是不利于应用开发的。相反,若是按照第一次的实现,isType先接收类型,再返回函数中接收判断类型的对象,这样由于每个函数都接收一个参数不会有二义性,其次先接收类型再接收对象也是符合常人逻辑思维,不会导致学习成本增加,从而更优雅的实现了isType。

当然,isType只是开端,如果你喜欢函数式编程的话,这里推荐看一下Ramda

4.3 回调函数

回调函数 就是指 那些作为其他函数实参的函数

其实在高阶函数中,已经介绍了回调函数的使用了,只是没有说概念而已。

以数组的map方法为例。

  • 明确我们 就是 map方法的使用者
  • 我们需要定义回调函数,即定义遍历的基本逻辑
  • 但回调函数我们不负责调用

这里为什么要强调身份呢?主要是因为在开发工具类函数时,我们会站在不同身份去思考具体的实现,这样才能开发出具有扩展性和适用性的函数。

言而总之,工具类函数与使用者之间沟通的媒介除了参数值,就是回调函数。而参数值仅仅一种数据值,但是回调函数是可以包装一段代码,运行在工具函数内部的。因此,回调函数会有更大发挥空间,利用好回调函数,会使你的工具类函数更加灵活多变。

回调函数这块知识点,就像是九阴真经的总纲是需要不断实践与总结才能体会的更深。这里是给大家提出一个思考的方向,最终悟出多少,就看个人的基础了。

4.4 闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起,这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

在JavaScript中,可执行代码被分为3种:

  • 全局代码
  • 函数体代码
  • eval代码

而三种代码对应三种作用域:

  • 全局作用域
  • 函数作用域
  • eval作用域

在实际使用中,我们常见的就是两种可执行代码:全局代码和函数体代码;同样,作用域也通常被划分为全局作用域和局部作用域(函数作用域)。

也就是说,函数体是在局部作用域范围内,在该范围内定义的变量都是局部作用域变量(局部变量)。在全局代码中,定义的那些变量是全局作用域变量(全局变量)。

回顾基本概念后,说回闭包。

闭包就是 函数 和 其词法环境引用的组合,这里的词法环境暂时我们简单的认为就是 函数的参数和函数体中定义的变量、函数等。

function outer(x){
    var y = 10;
    function inner(){
        console.log(x, y);
    }
}

这里函数inner以及outer形参x、变量y(词法环境)就形成了闭包。

总之,闭包也是JavaScript中函数的一种实际应用的体现。这里就不再详细介绍了,后面我会单独出一篇文章专门详解闭包。

4.5 IIFE

IIFE:Immediately Invoked Function Expression立即调用函数表达式

(function () {
  statements
})();

这是一个被称为 自执行匿名函数 的设计模式,主要包含两部分。

第一部分是包围在 圆括号运算符 () 里的一个匿名函数,这个匿名函数拥有独立的词法作用域。这不仅避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。

第二部分再一次使用 () 创建了一个立即执行函数表达式,JavaScript 引擎到此将直接执行函数。

示例: 当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问。

(function () {
    var name = 'DaXia';
})();
// 无法从外部访问变量 name
console.log(name) // 抛出错误:"Uncaught ReferenceError: name is not defined"

将 IIFE 分配给一个变量,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。

var _ = (function () {
   var name = 'GuoJing'
   return name
})();
// IIFE 执行后返回的结果:
console.log(_);  // GuoJing

这里为什么单独说这个IIFE呢?

可能有人听说过模块化这样的概念。前端模块化的实现就是基于IIFE的。比如早期JQuery,到中期的AMD requireJs以及CMD seaJs。

当然,目前前端ES已经推出了模块化规范ESM。但是不影响AMD 和 CMD曾经创造的辉煌。

4.6 递归函数

函数运行时,调用自身称为函数的递归调用。

递归函数在实际开发时一般应用于树形菜单的实现。也就是说,在遍历树形或者类树形结构时应用递归函数去实现会更加简单一些。所以递归函数的应用也就固定了。

function deepClone(){
    if(){
        deepClone()
    }
}

当然在实现深拷贝时,递归函数的方式实现是经常遇到的。剩下其他的场景,几乎也就不需要递归的出现了。

这里需要注意:递归时一般是要在满足一定条件下的,不然就会出现死递归,最终会导致调用栈溢出,从而引发异常。

4.7 构造函数

函数还有一个妙用,就是在面向对象编程时,配合new运算符去实例化一类对象。

// 构造函数命名规则:PascalCase
// 与其他普通函数区分开来
function Animal(name, color){
    // 通过this给对象添加属性
    this.name = name;
    this.color = color;
}

// 配合原型对象,将行为添加到原型上
Animal.prototype = {
    constructor: Animal,
    run(){},
    // ...
}

var cat = new Animal('hello-kity', 'black and white');

当一个new运算符配合函数调用时,函数执行的过程是和普通函数不一样的,这里大致包括:

  • 在执行函数体代码前,先是创建一个空对象O;
  • 将this绑定为O,同时设置其原型链
  • 创建函数运行所需环境(执行上下文)
  • 开始执行函数体代码
  • 如果没有显式return语句,那么直接返回this;如果有,返回值类型为基本数据类型直接忽略掉,否则返回指定的对象,替换掉返回this。

5. 函数对象常见属性和方法

函数既然是对象,那么就会自己的一些属性和方法。

5.1 属性

  1. name:顾名思义,用来存储函数的名称
  2. length:这个属性的含义是函数定义的形参个数
  3. caller:存储当前执行函数是在哪个函数下被调用的
  4. arguments:伪数组对象,存储函数运行时传入的所有实参
  5. prototype:原型属性
  6. __proto__或者<prototype>: 存储函数所继承的原型对象
function fn() {
    function test() {
        console.log(test.caller); // fn函数
    }

    test();
}
fn()

// OR
function f(cb) {
    cb();
}
f(function () {
    // arguments.callee表示当前正在执行的函数
    console.log(arguments.callee.caller); // f函数
});

5.2 方法

函数对象自身没有任何方法,但它的原型对象上有3个主要方法。

  1. bind():创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
  2. call():使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。
  3. apply():调用一个具有给定this值的函数,以及以一个数组(或类数组对象)的形式提供的参数。

注意: call()的语法和作用与 apply()类似,只有一个区别,就是 call() 方法接受的是一个参数列表,而 apply() 方法接受的是一个包含多个参数的数组

6. 总结

到目前为止,函数的其实还没有说完。比如 函数内部this绑定值分析、函数执行环境、以及一些小细节arguments、rest参数、参数解构等等。

后面,陆续会有 this以及函数执行环境相关文档。