JS函数式编程思维:柯里化、闭包

 偏函数(Partial Application):

探讨柯里化之前,我们先聊一聊很容易跟其混淆的另一个概念——偏函数(Partial Application)。在维基百科中,对 Partial Application 的定义是这样的:

In computer science, partial application (or partial function application) refers to the process of fixing a number of arguments to a function, producing another function of smaller arity.
其含义是:在计算机科学中,局部应用(或偏函数应用)是指将多个参数固定在一个函数中,从而产生另一个函数的过程。

举一个例子:假设一个工厂,用于生产梯形的零件,生产过程中我们要根据订单来源方给的一系列参数计算面积:
//声明一个计算梯形面积的函数:
function trapezoidArea(a,b,h){
    return (a+b)*h/2;
}

在订单中,大部分零件的高都是28的规格,此事经常调用面积函数就会变得冗余。
我们便可以以第一个函数为模板,来创建存储了固定值的新的计算函数:
//声明一个固定高度的梯形面积计算函数
function trapezoidAreaByHeight28(a,b){
    return trapezoidArea(a,b,28);
}
trapezoidAreaByHeight28(3,6);    // (3+6)*28/2

 当然,这个示例中并没有以明显的 偏函数 的方式去呈现,我们可以让返回结果变成一个新的函数,因此我们可以加以改造:

//声明一个计算梯形面积的函数
function trapezoidArea(a,b,h){
    return (a+b)*h/2
}
 声明一个【可以生成固定高度的梯形面积计算】的工厂函数
function trapezoidFactory(h){
    return function(a,b){
        return trapezoidArea(a,b,h)
    }
}

// 通过 trapezoidArea() 函数,生成绑定了固定参数的新的函数
const trapezoidAreaByHeight15 = trapezoidFactory(15);
const trapezoidAreaByHeight28 = trapezoidFactory(28);

trapezoidAreaByHeight15(6,13);   //142.5
trapezoidAreaByHeight28(6,13);    //266

 也可以将其简化为:

const trapezoidAreaByHeight33 = (a, b) => trapezoidArea.call(null, a, b, 33);
trapezoidAreaByHeight33(6, 13); // 结果: 313.5

这里,我们就可以将 trapezoidAreaByHeight15() 、trapezoidAreaByHeight28() 和 trapezoidAreaByHeight33() 视为 trapezoidArea() 的偏函数。

偏函数的应用
       偏函数往往不能改变一个函数的行为,通常是根据一个已有函数而生成一个新的函数,这个新的函数具有已有函数的相同功能,区别在于在新的函数中有一些参数已被固定不会变更。偏函数的设计通常:

减少了参数相似性高的函数调用过程;
降低了函数的通用性,提高了函数的适用性,使其更专注于做某事;
减少了程序耦合度,提高了专有函数的可维护性。

 

柯里化(Currying)

       柯里化(Currying)是以美国数理逻辑学家哈斯凯尔·科里(Haskell Curry)的名字命名的函数应用方式。与偏函数很像的地方是:都可以缓存参数,都会返回一个新的函数,以提高程序中函数的适用性。而不同点在于,柯里化(Currying)通常用于分解原函数式,将参数数量为 n 的一个函数,分解为参数数量为 1n 个函数,并且支持连续调用。例如:

//一个用于计算三个数字累加的函数
const addExample = function(a,b,c){
    return a+b+c;
}
//调用
addExample(10,5,3);    // 结果: 18

 通过柯里化,对上述函数进行演变
const addCurry = function(a){
    return function(b){
        return function(c){
            return a+b+c;
        }
    }
}

 缔造新的 单一元 函数
const add10 = addCurry(10)
const add15 = add10 (5)
const add18 = add15 (3)
// 调用
add18();    // 结果: 18

可见,柯里化(Currying)用于将多元任务分解成单一任务,每一个独立的任务都缓存了上一次函数生成时传递的入参,并且让新生成的函数更简单、专注。上述演变也可以写作:

// 通过ES6箭头函数构造将更加简单
const addCurry = (a) => (b) => (c) => a + b + c;

// 调用也可以这样
addCurry(10)(5)(3); // 结果: 18

柯里化的应用

       柯里化(Currying)分解了函数设计过程,将运行的步骤拆分为每一个单一参数的 lambda 演算。这里例举一个在 JavaScript 中用于做强制类型判断的示例:

// 创建一个用于检测数据类型的函数 checkType() 
const checkType = (e, typeStr) => Object.prototype.toString.call(e) === '[object '+typeStr+']';

// 调用示范
checkType(12, 'Number');            // 结果:true
checkType(16.8, 'Number');          // 结果:true
checkType(NaN, 'Number');           // 结果:true
checkType(Infinity, 'Number');      // 结果:true
checkType('abc', 'String');         // 结果:true
checkType(true, 'Boolean');         // 结果:true
checkType({}, 'Object');            // 结果:true
checkType([], 'Array');             // 结果:true
checkType(null, 'Null');            // 结果:true
checkType(undefined, 'Undefined');  // 结果:true
checkType(checkType, 'Function');   // 结果:true
checkType(Symbol(), 'Symbol');      // 结果:true

使用这一的方式构建的函数 checkType() 具备了高通用性,但适用性则略差。我们发现每次的调用过程,使用者都需要编写参数 typeStr 表示的类型字符串,增加了函数的应用复杂度。此时作为设计者,就可以对该函数加以改造,使其生成多个具备高适用性的独立函数:

// 检测值是否是 Number
const isNumber = (e) => checkType(e, 'Number');
// 检测值是否是 String
const isString = (e) => checkType(e, 'String');
// 检测值是否是 Boolean
const isBoolean = (e) => checkType(e, 'Boolean');
// 检测值是否是 Object
const isObject = (e) => checkType(e, 'Object');
// 检测值是否是 Array
const isArray = (e) => checkType(e, 'Array');
// 检测值是否是 Null
const isNull = (e) => checkType(e, 'Null');
// 检测值是否是 Undefined
const isUndefined = (e) => checkType(e, 'Undefined');
// 检测值是否是 Function
const isFunction = (e) => checkType(e, 'Function');
// 检测值是否是 Symbol
const isSymbol = (e) => checkType(e, 'Symbol');

柯里化无限调用

       柯里化(Currying)分解了函数设计过程,将运行的步骤拆分为每一个单一参数的 lambda 演算。我们可以通过递归的方式,来构造出一个可进行无限调用,并返回相同的累加函数的 柯里化函数

// 一个永远累加的函数,返回结果的新函数中缓存上一次调用,并进行数据累加
// 最终的数据依赖 success 回调函数获取
const alwaysAdd = function f1(nexter1){
    const n1 = nexter1;
    typeof n1.success == 'function' && n1.success(n1.value);
    return function f2(nexter2){
        const n2 = nexter2;
        return f1( {value: n1.value+n2.value, success: n2.success} );
    }
}

调用方式:
const r1 = alwaysAdd({value: 2});
const r2 = r1({value: 5});
const r3 = r2({value:4, success: (result)=>console.log('结果: ', result)});

以这样的方式,我们构建的参数是一个简单对象 nexter,该对象至少包含一个 value 属性,用于描述本次累加的值。如果希望获取累加结果,则为 nexter 对象赋予函数属性 success 即可。结果会以实参的形式,传递给 success 函数用于传递通知。

一个简单的 Promise

       Promise 对象无论是构造函数还是后续的链式调用中,都能看到柯里化设计的影子:接收单一参数,返回一个 Promise :

// 构建一个超级简单的 Promise 结构
class MyPromise{
    constructor(executor) {
        this.value = null;
        typeof executor == 'function' && executor(this.resolve.bind(this), this.reject.bind(this));
    }
                
    then(success){
        const result = success(this.value);
        const mp = new MyPromise();
        mp.value = result;
        return mp;
    }
                
    resolve(value){
        this.value = value;
    }
                
    reject(err){
        this.err = err;
    }
}

调用方式为:
// 构建一个 MyPromise 对象
const mp1 = new MyPromise((resolve, reject) => {
    resolve(10);
});

// 链式调用求值
mp1.then( r => {
    console.log('mp1 r => ', r);    // 结果: 10
    return r + 3;
} ).then( r => {
    console.log('mp2 r => ', r);    // 结果: 13
    return r + 5;
} ).then( r => {
    console.log('mp3 r => ', r);    // 结果: 18
} );

实现一个sum函数,使其同时满足以下两个调用需求:

sum(2,3);  //5         sum(2)(3);   //5

function fn(...data){
    if(data.length>1){
        return data.reduce((prev,next)=>Number(prev)+Number(next))
    }else{
        return function(data1){
            if(data1.length>1){
                fn(...data1)
            }
            return fn(data,data1)
        }
    }
}

实现一个twoSum函数,传入源数组和目标数字,返回源数组中两个相加起来等于目标数字的索引:

twoSum([2,7,11,15],9);    //[0,1],因为2+7=9

function fn(arr,count){
    const findIndex = arr.map((item,index)=>{
        const newArr = [...arr].slice(index+1,arr.length)
        const findNextIndex = newArr.findIndex(every=>(count-item) === every)
        return [index,findNextIndex === -1? findNextIndex: findNextIndex+index+1]
    })
    return findIndex.find(item=>!item.includes(-1))
}

闭包(Closure)

       闭包(Closure)是函数式编程的重要特性,是缓存函数内部作用域,并且对外暴露的过程;是模块化编程的基石。在探讨这部分的过程中,我们将主要探讨这几个问题:

  • JavaScript 作用域;

  • 面向对象关系;

  • this调用规则;

  • 配置多样化的构造重载;

  • 更多对象关系维护——模块化;

  • 流行的模块化方案;

1.JavaScript 作用域

作用域在编程语言中是一个广泛的概念,主要是指变量可应用的范围,应用的范围在广义上分为全局环境和局部环境,在运行机制上则是指变量被销毁的时机。

// 全局作用域中
const num1 = 10;

// 声明一个全局作用域中可调用的函数
function foo(){
    // 函数内部作为 局部作用域 ,此处声明的变量只有该函数内部持有
    const num2 = 29;

    console.log('局部  num1:', num1);
    console.log('局部  num2:', num2);
}

// 调用函数 foo()
// 子作用域中,共享父作用域中的变量,因此打印结果为:
// 局部  num1:10
// 局部  num2:29
foo();

// 但是,如果在外层的父作用域调用局部的自作用域中声明的变量:
console.log('全局  num1:', num1); // 结果:10
console.log('全局  num2:', num2); // 错误:ReferenceError: num2 is not defined

在JavaScript中,通常情况下一对{ }会限制一段代码运行的作用域,但也有特殊情况,如:

  • 当使用 { } 描述一个对象字面量值时,其表示一个对象常量;

  • { } 所描述的作用域是选择、循环结构,且其内部使用 var 关键字定义了变量时,var 关键字声明的变量,将可以在 { } 作用域之外引用。因此该行为不推荐,ES2015 引入的新的更加严格的声明变量的关键字 constlet 来解决 var 描述作用域不明确的历史问题。

JavaScript 内部,运行着一套与 Java 相似的自动垃圾回收机制,用于将之后不再使用的数据,在内存中清除掉。因此我们可以以这样的方式观测上述代码:

  1. 全局环境创建了变量 num1 并为其赋值 10num1 存储于内存之中;

  2. 全局环境创建了函数 foo ,并为其构建函数体,缓存于内存之中。未调用时不执行函数体内容;

  3. 函数 foo() 被调用;

  4. 发现函数 foo() 中需要创建变量 num2 ,则创建并赋值 29。变量 num2 存储于内存中;

  5. 通过 console.log() 输出变量 num1 的值于控制台,输出 10

  6. 通过 console.log() 输出变量 num2 的值于控制台,输出 29

  7. 函数 foo() 调用完毕,销毁函数中声明的变量 num2

  8. 返回外部作用域。

  9. 通过 console.log() 输出变量 num1 的值于控制台,输出 10

  10. 通过 console.log() 输出变量 num2 的值,JS引擎发现变量 num2 在当前作用域中并不存在(实际上在局部作用域运行时曾经存在过,但已经被销毁)。因此引发异常警告:ReferenceError: num2 is not defined

缓存作用域产生的结果

       以上是一段非常普通的 JS 运行示例,描述了程序运行过程中的作用域关系。在一段程序中,函数主要的作用就是为了解耦执行过程,提高可重用性。而在函数执行的过程中,我们通常都需要一个执行结果对外暴露,这就相当于扩大了函数内部运行数据的作用域,赋予了调用者可以跨子作用域获取数据的能力。这就是我们常见的函数返回值

// 声明一个全局作用域中可调用的函数
function foo(){
    // 函数内部作为 局部作用域 ,此处声明的变量只有该函数内部持有
    const num2 = 29;

    return num2;
}
// 接收函数 foo() 的执行结果
const num1 = foo();

// 在控制台输出 num1 所持有的值
console.log('全局  num1:', num1); // 结果:29

// 声明一个全局作用域中可调用的函数
function createObject(name, tel, age=18){
    // 如果 age 小于等于 0 ,则重新为其赋值为 18
    age = (age > 0) ? age : 18;
    
    // 返回一个描述 作者信息 的对象
    return {
        name, tel, age
    };
}
// 接收函数 createObject() 的执行结果
const user = createObject('blue', '12345678998', 16);

// 在控制台输出 作者信息
console.log('该文作者:', user); // 该文作者:{name: 'blue', tel: '18392019102', age: 16}

通过 高阶函数 特性,我们还了解到,由于函数本身也是一个对象,因此可以在一个函数中返回另一个函数

// 声明一个用于创建 二元计算器 的函数
// 函数接收一个 oper 作为运算操作符( 字符串,可用值:+,-,*,/,% )
function createCalculator(oper){
    
    // 新的函数接收用于计算的两个数字
    return function(a, b){
        switch(oper){
            case '+': return a+b;
            case '-': return a-b;
            case '*': return a*b;
            case '/': return a/b;
            case '%': return a%b;
            default: throw 'Operator Formart Error: ' + oper;
        }
    }
}
// 生成加法函数
const add = createCalculator('+');
const sub = createCalculator('-');

// 运行
add(10, 8);     // 结果: 18
sub(10, 8);     // 结果: 2

那么,如果我们希望返回的结果包含有多个函数时,怎么办呢?可以这么做:

// 声明一个用于创建 多元计算器 的函数
function createCalculator(){
    // 创建加法算法函数
    const addArithmetic = (accumulator, currentValue) => Number(accumulator) + Number(currentValue);
    // 创建加法运算函数,可接受 n 个数字用于累加
    const add = function() {
        return Array.from(arguments).reduce(addArithmetic);
    }
    // 创建减法算法函数
    const subArithmetic = (accumulator, currentValue) => Number(accumulator) - Number(currentValue);
    // 创建减法运算函数,可接受 n 个数字用于累减
    const sub = function() {
        return Array.from(arguments).reduce(subArithmetic);
    }
    // 将返回的 函数 作为对象成员
    return {
        add, sub
    };
}
// 构建计算器对象
const calculator = createCalculator();
// 调用
calculator.add(15, 33, 39, 69);     // 结果: 156
calculator.sub(69, 28, 15);         // 结果: 26

面向对象关系

       有过 面向对象 编程语言经验的同学,对于这种通过函数的方式缔造一个包含有多个属性或者函数的对象的方式都很熟悉,很像是通过一个类去创建对象。当然,以 面向对象 的方式,我们也可以解决上述问题,创建一个包含多个属性或者函数的对象:

// JS 中使用函数对象作为构造函数,来模拟类结构
// 用于缔造对象的函数,通常首字母大写
function Calculator(){
    // 函数作为构造函数,这里可以做一些初始化工作...
}

// 通过为函数的原型添加方法,以便于让子对象应用这些方法
Calculator.prototype.add = function(){
    // 创建加法算法函数
    const addArithmetic = (accumulator, currentValue) => accumulator + currentValue;
    return Array.from(arguments).reduce(addArithmetic);
}
Calculator.prototype.sub = function(){
    // 创建减法算法函数
    const subArithmetic = (accumulator, currentValue) => accumulator - currentValue;
    return Array.from(arguments).reduce(subArithmetic);
}

// 通过 new 关键字构建计算器对象
const calculator = new Calculator();
// 调用
calculator.add(15, 33, 39, 69);     // 结果: 156
calculator.sub(69, 28, 15);         // 结果: 26

与上一个例子,函数中返回对象字面量不同的是:函数返回的对象字面量构建的多个对象之间,调用的 add() 和 sub() 函数,在内存中都有各自独立的存储空间,互不干扰。但也造成了更多的资源损耗:

// 这里是示例 1:函数的方式返回计算器对象
const calc1 = createCalculator();
const calc2 = createCalculator();
calc1.add == calc2.add; // 结果:flase

而通过绑定原型 prototype 的方式构建的对象,在调用 add() 或 sub() 方法时,委托了原型链上层的对象模型,因此引用的都是同一个函数的副本。节省了内存资源:

// 这里是示例 2:构造函数调用的方式返回计算器对象
const calc1 = new Calculator();
const calc2 = new Calculator();
calc1.add == calc2.add; // 结果:true

 

闭包是一个缓存多个函数内部成员供函数外部引用的设计过程,思考这一设计过程时,方案的衍生、注意事项就变得尤为重要。

this调用规则

       无论是通过函数的方式,还是对象的方式进行数据封装及导出,我们都不可避免的遇到一个问题——在函数/对象内部,调用其他的内部成员。

类方法中的 this

       支持面向对象的编程语言通常都会有一个用于描述对象模板的结构,比如 JavaC# 中规定,以 class 作为关键字,声明一个 作为对象模板。在 中可以声明一些方法 (我们将隶属于某对象的函数称之为方法),而在 方法 的设计过程中,有的时候我们需要调用除 本方法 外的其他 类成员,需要使用关键字 this

// 以 Java 例举
class User{
    
    private String username; // 登录账号
    private String password; // 登录密码
    
    /**
     * 模拟登录方法
     */
    public void login(String u, String p){
        // 通过 this 关键字,可以调用成员变量
        this.username = u;
        this.password = p;
        
        // 通过 this 关键字,也可以调用其他成员方法
        this.print();
    }
    
    /**
     * 用于展示登录信息
     */
    private void print(){
        // 将登录信息输出至控制台
        System.out.println("欢迎您["+ this.username +"],您的密码是:" + this.password);
    }
}

而在被外部调用时,可能是这样的方式:

// 构建用户1:路飞
User user1 = new User();
user1.login("路飞", "lufei"); // 结果: 欢迎您[路飞],您的密码是:lufei

// 构建用户2:特拉法尔加·罗
User user2 = new User();
user2.login("特拉法尔加·罗", "law"); // 结果: 欢迎您[特拉法尔加·罗],您的密码是:law

在这个示例中,类模板 声明了方法 login() ,这个方法在类中也只算是一个 模板方法,用于描述 对象 在真正调用时的行为。方法中,通过关键字 this 可以引用类中定义的其他成员。这个 this 关键字所表示的意义就是:代指当前真正调用者(对象)。比如:

  • 对象 user1 调用方法时,方法内部的 this 就相当于是 user1

  • 对象 user2 调用方法时,方法内部的 this 就相当于是 user2

在不同的语言环境中,对于描述 当前对象 的方式也有着不同的变体,比如:

  • PHP 中使用关键字 $this

  • Swift 的类中使用 self 关键字描述类的实例本身;

  • Python 的类方法的第一个形参即代表类实例,通常命名都是 self

  • Ruby 使用操作符 @ 来在类中引用成员;

  • 一般情况下,JavaScript 中使用 this 来表示调用当前方法的对象。

JS 函数中的 this

       在 JavaScript 中,函数也可以独立存在(不定义在类中)。同时,每一个函数中也可以使用 this 关键字,但单独的函数中使用的 this 究竟代表了什么,却是一件应用规则比较复杂的事情。JS 函数中的 this 会在不同场景下遵循如下规则:

  • 默认规则

  • 严格模式

  • 隐式绑定

  • 显示绑定

  • new 绑定

  • 箭头函数绑定

默认规则

       默认规则是指,在默认情况下函数中使用 this 关键字时, this 所绑定的对象规则。在默认情况下, this 关键字指向的是全局对象

// 浏览器环境下
let foo = function() { 
    console.log(this);
}
foo(); // Window

// node.js 环境下
let foo = function() { 
    console.log(this);
}
foo(); // global

严格模式

       如果在严格模式(strict mode)下,这样的调用就不会绑定全局环境的对象,this 所指向的将是 undefined 值:

// 严格模式下
'use strict';
let foo = function() { 
    console.log(this);
}
foo(); // undefined

隐式绑定

       隐式绑定是我们在 JavaScript 中最常见的 this 绑定机制。他是指,当前函数的调用者。 实际上默认绑定规则也是隐式绑定的一种表现:因为在 JavaScript 环境中,如果未指定当前函数的调用者,其调用者就默认被当做是 全局对象

// 声明一个函数
function printExample() {
    console.log('调用者是:', this.name);
}

// 声明调用者 01
const user01 = {
    name: '娜美',
    print: printExample
};

// 声明调用者 02
const user02 = {
    name: '乌索普',
    print: printExample
};

// 调用对象方法
user01.print(); // 调用者是:娜美
user02.print(); // 调用者是:乌索普

甚至于,当我们将 user02 的 print() 方法赋值为 user01.print 时,只要最终调用的对象依然是 user02 那么结果也不会变化:

// 声明调用者 02
const user02 = {
    name: '乌索普',
    print: user01.print
};

// 调用对象方法
user01.print(); // 调用者是:娜美
user02.print(); // 调用者是:乌索普

隐式绑定规则非常简单,只要注意观测函数的直接调用者是谁即可。让我们来对上述代码做一个变体:

// 声明一个函数
function printExample() {
    console.log('调用者是:', this.name);
}
// 声明调用者 01
const user01 = {
    name: '娜美',
    print: printExample
};
// 声明调用者 02
const user02 = {
    name: '乌索普',
    print: printExample,
    user01: user01 // 此处,为对象 user02 添加 user01 作为属性
};
// 调用对象方法
user02.user01.print(); // 调用者是:娜美

该例中,虽然 user01 作为 user02 的属性,但最终调用时,依然是 user01 在调用 print() 方法,因此 this.name 获取到的属性值依然是 user01 对象中定义的值 娜美

显式绑定

       隐式绑定规则描述的情况是——虽然我们没刻意指定,但运行过程中隐式的帮我们做了 this 指定。那么相反的,显式绑定规则则是指:明确的指定了函数的调用者是谁
       在显式绑定规则中,我们通常使用函数对象的方法 bind()call()apply() 来进行描述:

Function.prototype.bind:函数用于创建一个新绑定函数,在新函数中, this 关键字将始终以 bind(thisArg) 中的参数 thisArg 作为绑定对象:

// 创建一个公共函数作为示例
function print() {
    console.log(this.name + '正在调用...');
}
// 定义对象
const user01 = {name : '索隆'};
const user02 = {name : '山治'};
// 使用 bind() 绑定调用对象,并将 print() 函数覆盖
print = print.bind(user01);
// 为 user02 对象创建 print() 方法
user02.print = print;
// 虽然调用者看起来是 user02
// 但 print() 方法中已将 this 绑定为 user01
// 因此 调用的结果是: 索隆正在调用...
user02.print();
Function.prototype.call: 函数用于调用另一个函数,并且在调用时指定 this 值,以及传递参数:
// 创建一个公共函数作为示例
function print(tricks) {
    console.log(this.name + '的绝招是:' + tricks);
}
// 定义对象
const user01 = {name : '索隆'};
const user02 = {
    name : '山治',
    print: print
};
// 调用 user02 对象的 print() 函数,但 实际调用对象已经指定了 user01
user02.print.call(user01, '三十六烦恼凤'); // 索隆的绝招是:三十六烦恼凤
// 将 print 函数的调用者绑定为 user02,并且调用
print.call(user02, '恶魔风脚'); // 山治的绝招是:恶魔风脚
// 普通调用时 this 指向了全局对象
print('龟派气功'); // 的绝招是:龟派气功

Function.prototype.apply:函数用于调用另一个函数,并且在调用时指定 this 值,以及传递参数。与 call 方法不同的是,call() 方法的参数部分是逐一传递的,而apply()的参数是作为数组形式传递给方法的第二个参数:
// call 方法的参数传递方式
// fun.call(thisArg, arg1, arg2, ...)

// apply 方法的参数传递方式
// fun.apply(thisArg, [argsArray])


new 绑定
       new 关键字用于调用一个构造函数,并缔造一个实体对象。而当 JavaScript 中的类成员方法中使用 this 关键字时,使用 new 构造的对象会绑定到方法中 主作用域 的 this。

// javascript 定义类的语法糖
class User{
    constructor(name){
        // 构造方法
        this.name = name;
    }
    print(){
        // 定义的普通方法
        console.log('我的名字是: ' + this.name);
    }
}
const user01 = new User('伊丽莎白');
user01.print(); // 我的名字是伊丽莎白

常见绑定问题——函数嵌套

       在 JavaScript 中,我们经常会遇到函数的嵌套语法。而且函数嵌套时,多个函数中的 this 关键字所指向的对象往往显得复杂、混乱,this 究竟指向外层函数作用域,还是内层的函数作用域呢?我们往往使用 代词 来描述外层作用域,解决这个问题:

// 声明一个对象
const obj = {
    arr: [ 1, 3, 5, 7, 9],
    seed: 3,
    calc: function(){
        // 通过当前对象的 arr 属性作为数组模板
        return this.arr.map(function(e, ind){
            // 对偶数位(2,4,6,...)数据进行运算,生成新的集合
            return ind % 2 !== 0 ? e + this.seed : e ;
        });
    }
};
obj.calc(); // 结果: [1, NaN, 5, NaN, 9]

为什么会出现这样的结果,偶数位数据运算后竟然都是 NaN ?原因很简单,在 calc() 函数的主作用域中的 this ,因为调用时调用者就是 obj ,所以 this.arr 引用到了属性 obj.arr 。而** this.arr.map 函数中,作为参数的函数也希望引用到 calc() 隐式绑定的 this 对象,但 map() 中的函数参数却由于 每个函数内部都拥有一个独立的 this,因为调用时的就近原则导致 this.seed 无法指向外层函数所绑定的 this 对象。**因此,内部的 this 指向了一个诡异的位置,而这个 this.seed 也并不是对象 objseed 属性。如何改造呢?

// 声明一个对象
const obj = {
    arr: [ 1, 3, 5, 7, 9],
    seed: 3,
    calc: function(){
        // 通过新的代词描述外部作用域的 this 对象
        const self = this;

        // 通过当前对象的 arr 属性作为数组模板
        return this.arr.map(function(e, ind){
            // 对偶数位(2,4,6,...)数据进行运算,生成新的集合
            // 注意:此处为了避免回调函数内部 this 冲突
            // 显式的调用了函数外层作用域中的 self 所代表的外层 this
            return ind % 2 !== 0 ? e + self.seed : e ;
        });
    }
};
obj.calc(); // 结果: [1, 6, 5, 10, 9]

箭头函数绑定

       通过 Lambda表达式 定义的箭头函数,其应用自身独有的一套规则,即:**捕获函数定义位置作用域的 this,作为自己函数内部的 this **。与此同时,其他 this 绑定规则 将无法影响箭头函数中已捕获的 this

// 全局作用域
window.bar = 'window 对象';

// 函数 foo 通过箭头函数定义
const foo = () => console.log(this.bar);

// 定义一个 baz 对象,添加函数 foo 作为方法
const baz = {
    bar: 'baz 对象',
    foo: foo
}

foo();              // 结果:window 对象
baz.foo();          // 结果:window 对象
foo.call(baz);      // 结果:window 对象
foo.bind(baz)();    // 结果:window 对象

通过如上特性,如果我们遇到了嵌套函数,也可以使用箭头函数来描述子函数作用域引用外层的父作用域的 this

// 声明一个对象
const obj = {
    arr: [ 1, 3, 5, 7, 9],
    seed: 3,
    calc: function(){

        // 通过当前对象的 arr 属性作为数组模板
        // 子函数作用域定义箭头函数的位置处于父函数的位置
        // 因此直接绑定了外层父函数作用域中的 this 引用
        return this.arr.map((e, ind) => {
            // 对偶数位(2,4,6,...)数据进行运算,生成新的集合
            return ind % 2 !== 0 ? e + this.seed : e ;
        });
    }
};

obj.calc(); // 结果: [1, 6, 5, 10, 9]

 

函数提供了一个封闭的、通用性很强的执行空间,根据参数不同,来缔造不同的执行结果。
       通过 JavaScript 作用域 ,我们了解了函数作用域的限制,我们可以将什么样的执行结果交给函数的调用者。
       通过 面向对象关系 我们了解到了另一种封装数据的方式——面向对象设计,可以帮助我们更准确的定义同一种类型的数据模型。
       通过对 this调用规则 的了解,我们可以规避很多在 JavaScript 中设计函数或对象的坑,更明确当前的封闭环境执行过程。

配置多样化的构造重载

       对象的设计往往主要需考虑实例之间的相似性,然而在应用对象的过程中,我们又常常要明确每个对象之间的个体独立性。因此面向对象的设计过程主要囊括以下两个步骤:

  • 抽象提取实例之间的共有属性、共有行为(方法);

  • 为确保实例独立性,提供共有属性不同的配置方式(赋值);

       通常我们会在构造函数中进行一系列初始化行为,用以配置这个对象的使用方式,入口依然是函数参数的传递:

class DateUtil{      
    constructor(date) {
        // 缓存日期对象
        this.date = typeof date === 'string'? new Date(date): date;
        if(this.date == 'Invalid Date'){
            throw '日期参数只能是有效格式的字符串或Date对象!';
        }
    }         
    // 按照 yyyy-MM-dd HH:mm:ss 格式输出字符串
    formart(){
        const d = this.date;
        const leftDate = [d.getFullYear(), d.getMonth()+1, d.getDate()];
        const rightDate = [d.getHours(), d.getMinutes(), d.getSeconds()];
        // 检查是否需要补零的函数
        const checkFormart = e => (''+e)[1]?(''+e):('0'+e);
        return leftDate.map( checkFormart ).join('-')
                .concat(' ')
                .concat(rightDate.map(checkFormart).join(':'));
    }
}

这是一个简单的日期工具类,在 constructor 构造中我们接收一个 date 参数作为该对象的默认配置,记录了构建对象时需要缓存的时间。又构建了一个 formart() 方法用于对于缓存的时间进行格式化字符串输出。当然,之后也许还有很多其他针对缓存数据 date 的操作,暂且不提。输出结果如下:

const d1 = new DateUtil(new Date());
d1.formart(); 

const d2 = new DateUtil('1998-3-12 9:3:21');
d2.formart(); 

重载是指对于同名方法的不同参数(类型/数量)的调用,用于以相似的行为描述不同结果。一些强类型语言中提供了重载的严格语法,而在 JavaScript 中,函数参数要求非常宽松——你可以传递任意数量的实参,以及声明任意数量的形参,即使他们在调用过程中发生了参数过剩或者参数缺失,也不会出现运行时异常。
       假设现在又有一个需求:缓存一个是否只操作日期(忽略时间部分)的状态。在 Java 中我们可能会这样设计这个类的构造重载:

class DateUtil{
    DateUtil(Date date){
        // 只接收日期类型对象的构造
    }

    DateUtil(String dateStr){
        // 只接收字符串类型参数的构造
    }

    DateUtil(Date date, boolean ignoreTime){
        // 接收日期类型对象,及是否忽略时间部分的布尔值
    }

    DateUtil(String dateStr, boolean ignoreTime){
        // 接收字符串类型参数,及是否忽略时间部分的布尔值
    }
}

而在 JavaScript 中,我们的构造函数只有一个,因此我们经常这样设计不同参数的可配置对象:接收统一的参数 options 对象,将需要传递的参数作为 options 对象的属性。在构造中断言属性的有效性:

class DateUtil{             
    constructor(options) {
        // 获取参数
        const {date , ignoreTime} = options;
        // 缓存日期对象
        this.date = typeof date === 'string'? new Date(date): date;
        if(this.date == 'Invalid Date'){
            throw '日期参数只能是有效格式的字符串或Date对象!';
        }
        // 缓存 是否忽略时间 的状态
        this.ignoreTime = (ignoreTime === true);
    }
                 
    // 按照 yyyy-MM-dd HH:mm:ss 格式输出字符串
    formart(){
        const d = this.date;
        // 日期部分数据集合
        const leftDate = [d.getFullYear(), d.getMonth()+1, d.getDate()];
        
        // 检查是否需要补零的函数
        const checkFormart = e => (''+e)[1]?(''+e):('0'+e);
        // 缓存日期部分的格式化字符串
        let result = leftDate.map( checkFormart ).join('-');
        // 根据是否忽略时间部分做为判断
        if(!this.ignoreTime){
            // 时间部分数据集合
            const rightDate = [d.getHours(), d.getMinutes(), d.getSeconds()];
            result = result.concat(' ')
                .concat(rightDate.map(checkFormart).join(':'));
        }
        return result;  
    }
}
调用过程就变成了:
const d1 = new DateUtil( {date: new Date()});
d1.formart(); // 结果: "2018-12-23 12:47:17"

const d2 = new DateUtil( {date: new Date(), ignoreTime: true});
d1.formart(); // 结果: "2018-12-23"

这样的设计过程也符合柯里化标准,接收单一参数,使得调用过程变得更加单纯。

更多对象关系维护——模块化

       通过将粒度比较小的函数进行封装,我们得到的是粒度更大一些的对象模型。而很多时候,我们需要完整的实现一个较为复杂的功能,就没法单靠一个对象模型承担所有任务了。我们可能需要构建更多的对象模型或者更多的独立函数,完成整体业务流程的调动关系。
       这种复杂的调动关系会形成一个完整的应用,很多插件的设计基准就来源于此。

立即执行函数 (IIFE)

       在 JavaScript 中,通过 匿名函数函数表达式 的组合,我们可以构建一个 立即执行函数(IIFE)

const foo = (function(){
    return 29;
})();
console.log( foo ); // 结果: 29

变量 foo 得到了匿名函数执行的返回值,因此赋值符右侧部分的函数并不仅仅是在声明,而是在声明之后立即执行立即执行函数 (IIFE) 主要的两个优势是:

  • 隔离外部作用域的全局变量,保持函数内部的纯净;

  • 立即执行产生结果,减少不必要的重复声明;

缓存执行结果

       闭包最大的作用,便是缓存函数中的执行结果,以便调用方多次引用,提高执行效率。我们将上述插件做一个改造,构建一个将 <div> 标签渲染成为一个表格的插件,代码如下:

<!-- index.html 部分 -->
<div id="box-1"></div>
/** b-table.css 部分 **/
div{
    box-sizing: border-box;
}

.b-table{
    width: 500px;
    border: 1px solid #eee;
}

.b-table .row{
    height: 30px;
    line-height: 30px;
}

.b-table .row:nth-child(even){
    background-color: antiquewhite;
}

.b-table .row .cols{
    display: inline-block;
    float: left;
    text-align: center;
}
// 独立的 jQuery 插件 build-table.js
(function($) {
    
    class Table{
        constructor({el, data}) {
            this.el = el;
            this.data = data;
            // 为当前调用者添加插件定义的类样式
            this.el.addClass('b-table');
        }
        
        rander(newData){
            // rander() 也可以接收新的数据重新渲染
            const data = newData || this.data;
            // 计算最大列数
            const colsWidth = data.reduce( (prev, curr) => Math.max(Array.isArray(prev)? prev.length: prev, curr.length) );
            // 计算列宽百分比, 动态调整样式
            const colsWidthPercent = (100 / colsWidth) + '%';
            // 生成用于渲染的 html 文本
            const tableHtml = data.map( r => `<div class="row">${r.map( c => '<div class="cols" style="width:'+colsWidthPercent+';">'+c+'</div>' ).join('')}</div>`);
            // 为缓存的 el 元素渲染内容
            this.el.html(tableHtml);
        }
    }
    
    // 为 jQuery 对象添加插件方法
    $.fn.extend({
        buildTable: function(data) {
            // 创建 Table 实例,传入配置项
            const t = new Table({el: this, data});
            // 调用渲染方法
            t.rander();
            // 返回当前的 Table 实例以便后续调用
            return t;
        }
    });
    
})(jQuery);

执行部分:

// index.html 中的js部分
<script src="js/jquery-3.3.1.min.js" type="text/javascript"></script>
<script src="js/build-table.js" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript">

    // 构建一个二维表数据用以渲染表格      
    const arr1 = [
        ['a', 'b', 'c', 'd'],
        ['e', 'f', 'g', 'h'],
        ['i', 'j', 'k'],
        ['l', 'm', 'n', 'o', 'p'],
    ];
    
    // 渲染后, 变量 box1 获取了插件闭包中返回的缓存数据
    const box1 = $('#box-1').buildTable(arr1);
</script>

 而当我们需要动态更新表格时,便可以使用变量 box1 进行重新渲染,而不需要再次指定该元素是哪个 div 元素:

// 为原始的 arr1 添加新数据
arr1.push(['q', 'r', 's', 't', 'u', 'v', 'w']);
// 重新将 arr1 数据渲染至 box1 指向的页面元素
box1.rander(arr1);

所谓模块化,即是指在解决复杂问题时,由上自下的将问题进行拆解,例如一些插件设计的目的即是为了提高可重用性。我们可以将若干功能拆解成为功能独立的小模块,互相嵌套引用。闭包则是拆解过程中常用的技巧。在更为复杂的模块设计中,一个闭包中涉及到的对象、函数调用关系会更为复杂的多,示例中仅仅希望以最简单的形式表达一些调用关系。

在一个封闭的执行空间中(如函数),调用执行时会在内存中创建其执行上下文。而在上下文执行完毕时,本应当销毁的执行结果并没有销毁,保留了下来由调用者继续引用。这就是闭包(Closure)
       闭包的表现形式可以是多样的,最稀松常见的便是返回一个对象,由调用主函数获取该对象这种方式。当然,也可以是非常复杂的过程构建,比如构建一个模块化的交互环境。

JavaScript 模块化表现

       早些年间,JavaScript 并不是很受人待见。所有的执行环境都在一个页面中混为一体,每一个 jser 都需要考虑自己写的外部 .js 文件如何能够不被其他的插件所干扰。很多相似的代码片段重复出现,大量的造轮子,搞的一个页面环境十分笨重。让人觉得 JavaScript 只能用来玩一玩,根本无谈工程化,上不了台面。
       终于,前辈们不堪其扰,决心杀出一条血路,为构建更优质的 js 环境而不懈努力。他们要解决的问题是:

  • 命名冲突:相似意义的变量或函数的命名冲突,可能会导致全局其他地方的引用产生歧义;

  • 功能解耦:一个功能强大的 js 插件往往包含诸多内容,致使插件设计者在维护时不堪重负。需要将一个复杂的问题解耦成若干简单的问题,并且互相协作引用。

  • 功能依赖:简单的诸多小的功能点可以被多处重复引用依赖,最终形成复杂的应用。因此需要配备成熟的依赖方式。

  • 工程维护:在设计上解构了一个复杂的应用,在人员配备方面便可以做到分工明确。由独立的人或团队负责某一个或一部分模块的设计,在接口引用的过程中只要符合标准,则开发效率也会大大提升,利于进一步维护。

AMD 规范

       AMD 即 Asynchronous Module Definition,中文名是“异步模块定义”的意思。它是一个在浏览器端模块化开发的规范。模块将被异步加载,模块加载不影响后面语句的运行。所有依赖某些模块的语句均放置在回调函数中。典型的实现是 requirejs

requirejs 中通过 define(id?, dependencies?, factory) 函数定义模块,参数表示的含义是:
id [可选]:定义中模块的名字。如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字。如果提供了该参数,模块名在应用环境中不允许重复。
dependencies [可选]:当前模块依赖的其他模块标识(模块名)所组成的数组字面量。如果忽略此参数,则默认为["require", "exports", "module"]。
factory : 模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值。

require.js 示例

1. 目录结构

// 当前示例的目录结构
├─ js
│  ├─ require.js
│  ├─ a.js
│  ├─ b.js
│  └─ c.js
├─ index.html

2. 页面引用

       在引用 require.js 时,我们标注了其他模块的加载主入口是 a.js,而无需把所有的模块都引入页面。

<body>
    <script src="js/require.js" type="text/javascript" charset="utf-8" data-main="js/a.js"></script>
    <script type="text/javascript">
        console.log('page loaded..');
    </script>
</body>

3. 模块定义

// 文件: a.js
// 假设模块 a 依赖于模块 c 和 b
define('a', ['c', 'b'], function(c, b){
    
    // 模块 a 的执行环境
    console.log('a被加载...');
    
    c.printC('a');
    b.printB('a');

    console.log('a加载完毕...');
    
});

之后是模块 c 和 b 的定义

// 文件: c.js
define('c', function(){
    console.log('c被加载...');
    
    // 定义函数 printC
    const printC = function(who){
        console.log(who + ' print C!!!');
    }
    // 导出调用对象
    return {
        printC
    };
});
// 文件: b.js
define('b', function(){
    console.log('b被加载...');

    // 定义函数 printC
    const printB = function(who){
        console.log(who + ' print B!!!');
    }
    // 导出调用对象
    return {
        printB
    };
});

4. 执行结果

requirejs 以异步的方式进行模块定义,并未阻塞程序主线程。因此其他的 js 活动可以正常运行。而在加载模块依赖的过程中,则会根据声明的依赖标识逐一加载。此时,并无关联性的三个 js 文件就可以互相引用,完成了功能解构。

CMD 规范

       CMD 即 Common Module Definition,通用模块定义,是国内发展起来的一套 js 模块化规范,所解决的问题与 AMD 规范相同,只不过对于模块的定义方式和加载时机略有不同。代表产品有 Alibaba 的玉伯所设计的 Sea.js。与 AMD 规范不同的是 AMD 规范推崇依赖加载前置,而 CMD 规范推崇依赖就近。从依赖调用过程中我们就能看出他们之间的差别。

sea.js 示例

1. 目录结构

// 当前示例的目录结构
├─ js
│  ├─ sea.js
│  ├─ aa.js
│  ├─ bb.js
│  └─ cc.js
├─ index.html

2. 页面引用

       使用 sea.js 的过程中,我们可以并不指定任何其他模块的显式导入,而是在需要使用时再手动调用:

<script src="js/sea.js" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript">
    
    // 通过 seajs 对象调用 use() 方法获取其他模块的引用
    seajs.use('./js/aa.js', function (aa) {
        aa.printBB();
        aa.printCC();
    });
    
    // 未阻塞的页面其他 js 语句
    console.log('page loaded...');
            
</script>

3. 模块定义

// aa.js
define(function (require, exports, module) {
    
    console.log('aa被加载...');
    
    // 在需要时引用的其他模块,而非定义时描述模块
    const cc = require('./cc');
    const bb = require('./bb');
    
    // 通过 module.exports 导出模块
    module.exports = {
        printCC: cc.print,
        printBB: bb.print
    };
});

其他的依赖模块 bb.js 和 cc.js

// bb.js
define(function (require, exports, module) {
    
    console.log('bb被加载...');
    
    // 导出模块内容
    module.exports = {
        print: function(){
            console.log('bb 执行 print()!');
        }
    };
});
// cc.js
define(function (require, exports, module) {
    
    console.log('cc被加载...');
    
    // 导出模块内容
    module.exports = {
        print: function(){
            console.log('cc 执行 print()!');
        }
    };
});

4. 执行结果

AMD 规范最大的不同,便是对于模块的加载时机:aa 首先被加载,在 aa 模块执行的过程中按需加载了 ccbb 模块。
       不过值得一提的是,随着 ES6 中对模块化的定义以及普及,民间的优秀规范也渐渐被弃用。Sea.js 上一次的更新时间是 2014 年,已停止维护。但他们都是非常优秀的闭包案例,值得学习。

CommonJS 规范

       CommonJS 规范 是 node.js 中的模块化规范,其应用方式与 CMD 规范十分相似,但要简化一些。CommonJS 规范 的实现方式是:

(function(exports, require, module, __filename, __dirname){
  return module.exports;
});

熟悉 node.js 环境的同学能看到在模块参数中,node为我们传递了全局变量 __filename__dirname 以便于更方便的引用。而我们自己在写基于 node.js 环境的 js 源码时,便可以很方便的直接这样定义:

let fs = require('fs');

// 定义一个读取文件返回 Promise 对象的异步函数
let readFile = (txtOrig) => new Promise((resolve, reject) => {
    fs.readFile(txtOrig, {encoding: 'utf8'}, (err, data) => {
        if(err) reject(err);
        resolve(data);
    });
});

module.exports = {
    readFile
}

模块定义的语法被隐去了,留给开发者的是可以更加关注业务流程,减少了代码冗余。

ES6 模块规范

       ES6 模块规范 是官方拟定的一套模块加载规范,其模块引用方式和语法都与前者略有不同。

模块导出

       ES6 通过 export defaultexport 两种语法进行模块导出,导出的结果是一个模块对象,而两种语法会分别将导出结果作为这个模块对象的属性。他们之间的差别是:

export default:导出的值作为模块对象default 属性,可由导入方进行重命名。一个模块中只允许出现一个 export default
export:导出的语法必须是一个声明语句,声明的标识符(变量名或函数名)会作为导出的模块对象的属性进行动态绑定。一个模块中可以出现多个 export 语法,并且可以与 export default 语法共存。

示例:

// 模块 foo
export const num1 = 10;
export const num2 = 19;
export default function(a, b){
    return a + b;
}

模块导入

接收默认导入:

// 模块 bar
// 默认导入时可以为导入的结果重新命名
import calc from './foo';
calc(2, 3); // 结果: 5

接收声明导入:

// 模块 bar
// 通过 export 导出的声明,必须以原命名进行导入
import {num1, num2} from './foo';
num1; // 结果: 10
num2; // 结果: 19

全部导入:

// 模块 bar
import calc, {num1, num2} from './foo';
calc(num1, num2); // 结果: 29

通配符导入:

// 模块 bar
import calc, * as Num from './foo';
calc(Num.num1, Num.num2); // 结果: 29

总结:闭包是一个保护执行环境、缓存执行结果的设计方式。在各种支持函数式编程的语言中我们都能看到闭包的身影,它的概念很模糊,却无处不在。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值