JavaScript设计模式与开发实践
上QQ阅读APP看书,第一时间看更新

3.2 高阶函数

高阶函数是指至少满足下列条件之一的函数。

❏ 函数可以作为参数被传递;

❏ 函数可以作为返回值输出。

JavaScript语言中的函数显然满足高阶函数的条件,在实际开发中,无论是将函数当作参数传递,还是让函数的执行结果返回另外一个函数,这两种情形都有很多应用场景,下面就列举一些高阶函数的应用场景。

3.2.1 函数作为参数传递

把函数当作参数传递,这代表我们可以抽离出一部分容易变化的业务逻辑,把这部分业务逻辑放在函数参数中,这样一来可以分离业务代码中变化与不变的部分。其中一个重要应用场景就是常见的回调函数。

1.回调函数

在ajax异步请求的应用中,回调函数的使用非常频繁。当我们想在ajax请求返回之后做一些事情,但又并不知道请求返回的确切时间时,最常见的方案就是把callback函数当作参数传入发起ajax请求的方法中,待请求完成之后执行callback函数:

        var getUserInfo = function( userId, callback ){
            $.ajax( 'http://xxx.com/getUserInfo? ' + userId, function( data ){
              if ( typeof callback === 'function' ){
                  callback( data );
              }
            });
        }

        getUserInfo( 13157, function( data ){
            alert ( data.userName );
        });

回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些请求时,我们也可以把这些请求封装成一个函数,并把它作为参数传递给另外一个函数,“委托”给另外一个函数来执行。

比如,我们想在页面中创建100个div节点,然后把这些div节点都设置为隐藏。下面是一种编写代码的方式:

        var appendDiv = function(){
            for ( var i = 0; i < 100; i++ ){
              var div = document.createElement( 'div' );
              div.innerHTML = i;
              document.body.appendChild( div );
              div.style.display = 'none';
            }
        };

        appendDiv();

把div.style.display = 'none’的逻辑硬编码在appendDiv里显然是不合理的,appendDiv未免有点个性化,成为了一个难以复用的函数,并不是每个人创建了节点之后就希望它们立刻被隐藏。

于是我们把div.style.display = 'none’这行代码抽出来,用回调函数的形式传入appendDiv方法:

        var appendDiv = function( callback ){
          for ( var i = 0; i < 100; i++ ){
              var div = document.createElement( 'div' );
              div.innerHTML = i;
              document.body.appendChild( div );
              if ( typeof callback === 'function' ){
                  callback( div );
              }
          }
       };

       appendDiv(function( node ){
          node.style.display = 'none';
       });

可以看到,隐藏节点的请求实际上是由客户发起的,但是客户并不知道节点什么时候会创建好,于是把隐藏节点的逻辑放在回调函数中,“委托”给appendDiv方法。appendDiv方法当然知道节点什么时候创建好,所以在节点创建好的时候,appendDiv会执行之前客户传入的回调函数。

2. Array.prototype.sort

Array.prototype.sort接受一个函数当作参数,这个函数里面封装了数组元素的排序规则。从Array.prototype.sort的使用可以看到,我们的目的是对数组进行排序,这是不变的部分;而使用什么规则去排序,则是可变的部分。把可变的部分封装在函数参数里,动态传入Array.prototype.sort,使Array.prototype.sort方法成为了一个非常灵活的方法,代码如下:

        //从小到大排列

        [ 1, 4, 3 ].sort( function( a, b ){
            return a - b;
        });

        // 输出: [ 1, 3, 4 ]

        //从大到小排列

        [ 1, 4, 3 ].sort( function( a, b ){
            return b - a;
        });

        // 输出: [ 4, 3, 1 ]

3.2.2 函数作为返回值输出

相比把函数当作参数传递,函数当作返回值输出的应用场景也许更多,也更能体现函数式编程的巧妙。让函数继续返回一个可执行的函数,意味着运算过程是可延续的。

1.判断数据的类型

我们来看看这个例子,判断一个数据是否是数组,在以往的实现中,可以基于鸭子类型的概念来判断,比如判断这个数据有没有length属性,有没有sort方法或者slice方法等。但更好的方式是用Object.prototype.toString来计算。Object.prototype.toString.call( obj )返回一个字符串,比如Object.prototype.toString.call( [1,2,3] )总是返回"[object Array]",而Object.prototype.toString.call( “str”)总是返回"[object String]"。所以我们可以编写一系列的isType函数。代码如下:

        var isString = function( obj ){
            return Object.prototype.toString.call( obj ) === '[object String]';
        };

        var isArray = function( obj ){
            return Object.prototype.toString.call( obj ) === '[object Array]';
        };

        var isNumber = function( obj ){
            return Object.prototype.toString.call( obj ) === '[object Number]';
        };

我们发现,这些函数的大部分实现都是相同的,不同的只是Object.prototype.toString. call( obj )返回的字符串。为了避免多余的代码,我们尝试把这些字符串作为参数提前值入isType函数。代码如下:

        var isType = function( type ){
            return function( obj ){
              return Object.prototype.toString.call( obj ) === '[object '+ type +']';
            }
        };
        var isString = isType( 'String' );
        var isArray = isType( 'Array' );
        var isNumber = isType( 'Number' );

        console.log( isArray( [ 1, 2, 3 ] ) );     // 输出:true

我们还可以用循环语句,来批量注册这些isType函数:

        var Type = {};

        for ( var i = 0, type; type = [ 'String', 'Array', 'Number' ][ i++ ]; ){
            (function( type ){
              Type[ 'is' + type ] = function( obj ){
                  return Object.prototype.toString.call( obj ) === '[object '+ type +']';
                  }
              })( type )
        };

        Type.isArray( [] );     // 输出:true
        Type.isString( "str" );     // 输出:true

2. getSingle

下面是一个单例模式的例子,在第三部分设计模式的学习中,我们将进行更深入的讲解,这里暂且只了解其代码实现:

        var getSingle = function ( fn ) {
            var ret;
            return function () {
              return ret || ( ret = fn.apply( this, arguments ) );
            };
        };

这个高阶函数的例子,既把函数当作参数传递,又让函数执行后返回了另外一个函数。我们可以看看getSingle函数的效果:

        var getScript = getSingle(function(){
            return document.createElement( 'script' );
        });

        var script1 = getScript();
        var script2 = getScript();
        alert ( script1 === script2 );    // 输出:true

3.2.3 高阶函数实现AOP

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过“动态织入”的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。

在Java语言中,可以通过反射和动态代理机制来实现AOP技术。而在JavaScript这种动态语言中,AOP的实现更加简单,这是JavaScript与生俱来的能力。

通常,在JavaScript中实现AOP,都是指把一个函数“动态织入”到另外一个函数之中,具体的实现技术有很多,本节我们通过扩展Function.prototype来做到这一点。代码如下:

        Function.prototype.before = function( beforefn ){
            var __self = this;    // 保存原函数的引用
            return function(){    // 返回包含了原函数和新函数的"代理"函数
              beforefn.apply( this, arguments );     // 执行新函数,修正this
              return __self.apply( this, arguments );    // 执行原函数
            }
        };

        Function.prototype.after = function( afterfn ){
            var __self = this;
            return function(){
              var ret = __self.apply( this, arguments );
              afterfn.apply( this, arguments );
              return ret;
            }
        };

        var func = function(){
            console.log( 2 );
        };

        func = func.before(function(){
            console.log( 1 );
        }).after(function(){
            console.log( 3 );
        });

        func();

我们把负责打印数字1和打印数字3的两个函数通过AOP的方式动态植入func函数。通过执行上面的代码,我们看到控制台顺利地返回了执行结果1、2、3。

这种使用AOP的方式来给函数添加职责,也是JavaScript语言中一种非常特别和巧妙的装饰者模式实现。这种装饰者模式在实际开发中非常有用,我们将在第15章进行详细的讲解。有兴趣的读者可以提前翻阅第15章进行了解。

3.2.4 高阶函数的其他应用

前面我们已经学习过高阶函数,本节我们再挑选一些常见的高阶函数应用进行介绍。

1. currying

首先我们讨论的是函数柯里化(function currying)。currying的概念最早由俄国数学家Moses Schönfinkel发明,而后由著名的数理逻辑学家Haskell Curry将其丰富和发展,currying由此得名。

currying又称部分求值。一个currying的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。

从字面上理解currying并不太容易,我们来看下面的例子。

假设我们要编写一个计算每月开销的函数。在每天结束之前,我们都要记录今天花掉了多少钱。代码如下:

        var monthlyCost = 0;

        var cost = function( money ){
            monthlyCost += money;
        };

        cost( 100 );    // 第1天开销
        cost( 200 );    // 第2天开销
        cost( 300 );    // 第3天开销
        //cost( 700 );    // 第30天开销

        alert ( monthlyCost );      // 输出:600

通过这段代码可以看到,每天结束后我们都会记录并计算到今天为止花掉的钱。但我们其实并不太关心每天花掉了多少钱,而只想知道到月底的时候会花掉多少钱。也就是说,实际上只需要在月底计算一次。

如果在每个月的前29天,我们都只是保存好当天的开销,直到第30天才进行求值计算,这样就达到了我们的要求。虽然下面的cost函数还不是一个currying函数的完整实现,但有助于我们了解其思想:

        var cost = (function(){
            var args = [];

            return function(){
              if ( arguments.length === 0 ){
                  var money = 0;
                  for ( var i = 0, l = args.length; i < l; i++ ){
                      money += args[ i ];
                  }
                  return money;
              }else{
                  [].push.apply( args, arguments );
              }
            }

        })();

        cost( 100 );    // 未真正求值
        cost( 200 );    // 未真正求值
        cost( 300 );    // 未真正求值

        console.log( cost() );       // 求值并输出:600

接下来我们编写一个通用的function currying(){}, function currying(){}接受一个参数,即将要被currying的函数。在这个例子里,这个函数的作用遍历本月每天的开销并求出它们的总和。代码如下:

        var currying = function( fn ){
            var args = [];

            return function(){
              if ( arguments.length === 0 ){
                  return fn.apply( this, args );
              }else{
                  [].push.apply( args, arguments );
                  return arguments.callee;
              }
            }

        };

        var cost = (function(){
            var money = 0;

            return function(){
              for ( var i = 0, l = arguments.length; i < l; i++ ){
                  money += arguments[ i ];
              }
              return money;
            }

        })();

        var cost = currying( cost );    // 转化成currying函数

        cost( 100 );    // 未真正求值
        cost( 200 );    // 未真正求值
        cost( 300 );    // 未真正求值
        alert ( cost() );     // 求值并输出:600

至此,我们完成了一个currying函数的编写。当调用cost()时,如果明确地带上了一些参数,表示此时并不进行真正的求值计算,而是把这些参数保存起来,此时让cost函数返回另外一个函数。只有当我们以不带参数的形式执行cost()时,才利用前面保存的所有参数,真正开始进行求值计算。

2. uncurrying

在JavaScript中,当我们调用对象的某个方法时,其实不用去关心该对象原本是否被设计为拥有这个方法,这是动态类型语言的特点,也是常说的鸭子类型思想。

同理,一个对象也未必只能使用它自身的方法,那么有什么办法可以让对象去借用一个原本不属于它的方法呢?

答案对于我们来说很简单,call和apply都可以完成这个需求:

        var obj1 = {
            name: 'sven'
        };

        var obj2 = {
            getName: function(){
              return this.name;
            }
        };

        console.log( obj2.getName.call( obj1 ) );     // 输出:sven

我们常常让类数组对象去借用Array.prototype的方法,这是call和apply最常见的应用场景之一:

        (function(){
            Array.prototype.push.call( arguments, 4 );    // arguments借用Array.prototype.push方法
            console.log( arguments );      // 输出:[1, 2, 3, 4]
        })( 1, 2, 3 );

在我们的预期中,Array.prototype上的方法原本只能用来操作array对象。但用call和apply可以把任意对象当作this传入某个方法,这样一来,方法中用到this的地方就不再局限于原来规定的对象,而是加以泛化并得到更广的适用性。

Array.prototype上的方法可以操作任何对象的原理可参阅2.2节。

那么有没有办法把泛化this的过程提取出来呢?本小节讲述的uncurrying就是用来解决这个问题的。uncurrying的话题来自JavaScript之父Brendan Eich在2011年发表的一篇Twitter。以下代码是uncurrying的实现方式之一:

        Function.prototype.uncurrying = function () {
            var self = this;
            return function() {
              var obj = Array.prototype.shift.call( arguments );
              return self.apply( obj, arguments );
          };
        };

在讲解这段代码的实现原理之前,我们先来瞧瞧它有什么作用。

在类数组对象arguments借用Array.prototype的方法之前,先把Array.prototype.push.call这句代码转换为一个通用的push函数:

        var push = Array.prototype.push.uncurrying();

        (function(){
            push( arguments, 4 );
            console.log( arguments );     // 输出:[1, 2, 3, 4]
        })( 1, 2, 3 );

通过uncurrying的方式,Array.prototype.push.call变成了一个通用的push函数。这样一来,push函数的作用就跟Array.prototype.push一样了,同样不仅仅局限于只能操作array对象。而对于使用者而言,调用push函数的方式也显得更加简洁和意图明了。

我们还可以一次性地把Array.prototype上的方法“复制”到array对象上,同样这些方法可操作的对象也不仅仅只是array对象:

        for ( var i = 0, fn, ary = [ 'push', 'shift', 'forEach' ]; fn = ary[ i++ ]; ){
            Array[ fn ] = Array.prototype[ fn ].uncurrying();
        };

        var obj = {
            "length": 3,
            "0": 1,
            "1": 2,
            "2": 3
        };

        Array.push( obj, 4 );     // 向对象中添加一个元素
        console.log( obj.length );    // 输出:4

        var first = Array.shift( obj );    // 截取第一个元素
        console.log( first );     // 输出:1
        console.log( obj );    // 输出:{0: 2, 1: 3, 2: 4, length: 3}

        Array.forEach( obj, function( i, n ){
            console.log( n );      // 分别输出:0, 1, 2
        });

甚至Function.prototype.call和Function.prototype.apply本身也可以被uncurrying,不过这没有实用价值,只是使得对函数的调用看起来更像JavaScript语言的前身Scheme:

        var call = Function.prototype.call.uncurrying();
        var fn = function( name ){
            console.log( name );
        };
        call( fn, window, 'sven' );     // 输出:sven

        var apply = Function.prototype.apply.uncurrying();
        var fn = function( name ){
            console.log( this.name );     // 输出:"sven"
            console.log( arguments );     // 输出: [1, 2, 3]
        };
        apply( fn, { name: 'sven' }, [ 1, 2, 3 ] );

目前我们已经给出了Function.prototype.uncurrying的一种实现。现在来分析调用Array.prototype.push.uncurrying()这句代码时发生了什么事情:

        Function.prototype.uncurrying = function () {
            var self = this;     // self此时是Array.prototype.push
            return function() {
              var obj = Array.prototype.shift.call( arguments );
              // obj是{
              //    "length": 1,
              //    "0": 1
              // }
              // arguments对象的第一个元素被截去,剩下[2]
              return self.apply( obj, arguments );
              // 相当于Array.prototype.push.apply( obj, 2 )
            };
        };

        var push = Array.prototype.push.uncurrying();
        var obj = {
            "length": 1,
            "0": 1
        };

        push( obj, 2 );
        console.log( obj );     // 输出:{0: 1, 1: 2, length: 2}

除了刚刚提供的代码实现,下面的代码是uncurrying的另外一种实现方式:

        Function.prototype.uncurrying = function(){
            var self = this;
            return function(){
              return Function.prototype.call.apply( self, arguments );
            }
        };

3.函数节流

JavaScript中的函数大多数情况下都是由用户主动调用触发的,除非是函数本身的实现不合理,否则我们一般不会遇到跟性能相关的问题。但在一些少数情况下,函数的触发不是由用户直接控制的。在这些场景下,函数有可能被非常频繁地调用,而造成大的性能问题。下面将列举一些这样的场景。

(1) 函数被频繁调用的场景

❏ window.onresize事件。我们给window对象绑定了resize事件,当浏览器窗口大小被拖动而改变的时候,这个事件触发的频率非常之高。如果我们在window.onresize事件函数里有一些跟DOM节点相关的操作,而跟DOM节点相关的操作往往是非常消耗性能的,这时候浏览器可能就会吃不消而造成卡顿现象。

❏ mousemove事件。同样,如果我们给一个div节点绑定了拖曳事件(主要是mousemove),当div节点被拖动的时候,也会频繁地触发该拖曳事件函数。

❏ 上传进度。微云的上传功能使用了公司提供的一个浏览器插件。该浏览器插件在真正开始上传文件之前,会对文件进行扫描并随时通知JavaScript函数,以便在页面中显示当前的扫描进度。但该插件通知的频率非常之高,大约一秒钟10次,很显然我们在页面中不需要如此频繁地去提示用户。

(2) 函数节流的原理

我们整理上面提到的三个场景,发现它们面临的共同问题是函数被触发的频率太高。

比如我们在window.onresize事件中要打印当前的浏览器窗口大小,在我们通过拖曳来改变窗口大小的时候,打印窗口大小的工作1秒钟进行了10次。而我们实际上只需要2次或者3次。这就需要我们按时间段来忽略掉一些事件请求,比如确保在500ms内只打印一次。很显然,我们可以借助setTimeout来完成这件事情。

(3) 函数节流的代码实现

关于函数节流的代码实现有许多种,下面的throttle函数的原理是,将即将被执行的函数用setTimeout延迟一段时间执行。如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求。throttle函数接受2个参数,第一个参数为需要被延迟执行的函数,第二个参数为延迟执行的时间。具体实现代码如下:

        var throttle = function ( fn, interval ) {

            var __self = fn,    // 保存需要被延迟执行的函数引用
              timer,      // 定时器
              firstTime = true;    // 是否是第一次调用

            return function () {
              var args = arguments,
                  __me = this;

              if ( firstTime ) {    // 如果是第一次调用,不需延迟执行
                  __self.apply(__me, args);
                  return firstTime = false;
              }

              if ( timer ) {    // 如果定时器还在,说明前一次延迟执行还没有完成
                  return false;
              }

              timer = setTimeout(function () {  // 延迟一段时间执行
                  clearTimeout(timer);
                  timer = null;
                  __self.apply(__me, args);

              }, interval || 500 );

          };

        };

        window.onresize = throttle(function(){
          console.log( 1 );
        }, 500 );

4.分时函数

在前面关于函数节流的讨论中,我们提供了一种限制函数被频繁调用的解决方案。下面我们将遇到另外一个问题,某些函数确实是用户主动调用的,但因为一些客观的原因,这些函数会严重地影响页面性能。

一个例子是创建WebQQ的QQ好友列表。列表中通常会有成百上千个好友,如果一个好友用一个节点来表示,当我们在页面中渲染这个列表的时候,可能要一次性往页面中创建成百上千个节点。

在短时间内往页面中大量添加DOM节点显然也会让浏览器吃不消,我们看到的结果往往就是浏览器的卡顿甚至假死。代码如下:

        var ary = [];

        for ( var i = 1; i <= 1000; i++ ){
            ary.push( i );     // 假设ary装载了1000个好友的数据
        };

        var renderFriendList = function( data ){
            for ( var i = 0, l = data.length; i < l; i++ ){
              var div = document.createElement( 'div' );
              div.innerHTML = i;
              document.body.appendChild( div );
            }
        };

        renderFriendList( ary );

这个问题的解决方案之一是下面的timeChunk函数,timeChunk函数让创建节点的工作分批进行,比如把1秒钟创建1000个节点,改为每隔200毫秒创建8个节点。

timeChunk函数接受3个参数,第1个参数是创建节点时需要用到的数据,第2个参数是封装了创建节点逻辑的函数,第3个参数表示每一批创建的节点数量。代码如下:

        var timeChunk = function( ary, fn, count ){

            var obj,
              t;

            var len = ary.length;

            var start = function(){
              for ( var i = 0; i < Math.min( count || 1, ary.length ); i++ ){
                  var obj = ary.shift();
                  fn( obj );
              }
            };

            return function(){
              t = setInterval(function(){
                if ( ary.length === 0 ){  // 如果全部节点都已经被创建好
                    return clearInterval( t );
                }
                start();
              }, 200 );    // 分批执行的时间间隔,也可以用参数的形式传入

            };

        };

最后我们进行一些小测试,假设我们有1000个好友的数据,我们利用timeChunk函数,每一批只往页面中创建8个节点:

        var ary = [];

        for ( var i = 1; i <= 1000; i++ ){
            ary.push( i );
        };

        var renderFriendList = timeChunk( ary, function( n ){
            var div = document.createElement( 'div' );
            div.innerHTML = n;
            document.body.appendChild( div );
        }, 8 );

        renderFriendList();

5.惰性加载函数

在Web开发中,因为浏览器之间的实现差异,一些嗅探工作总是不可避免。比如我们需要一个在各个浏览器中能够通用的事件绑定函数addEvent,常见的写法如下:

        var addEvent = function( elem, type, handler ){
            if ( window.addEventListener ){
              return elem.addEventListener( type, handler, false );
              }
              if ( window.attachEvent ){
                  return elem.attachEvent( 'on' + type, handler );
              }
        };

这个函数的缺点是,当它每次被调用的时候都会执行里面的if条件分支,虽然执行这些if分支的开销不算大,但也许有一些方法可以让程序避免这些重复的执行过程。

第二种方案是这样,我们把嗅探浏览器的操作提前到代码加载的时候,在代码加载的时候就立刻进行一次判断,以便让addEvent返回一个包裹了正确逻辑的函数。代码如下:

        var addEvent = (function(){
            if ( window.addEventListener ){
              return function( elem, type, handler ){
                  elem.addEventListener( type, handler, false );
              }
            }
            if ( window.attachEvent ){
              return function( elem, type, handler ){
                  elem.attachEvent( 'on' + type, handler );
              }
            }
        })();

目前的addEvent函数依然有个缺点,也许我们从头到尾都没有使用过addEvent函数,这样看来,前一次的浏览器嗅探就是完全多余的操作,而且这也会稍稍延长页面ready的时间。

第三种方案即是我们将要讨论的惰性载入函数方案。此时addEvent依然被声明为一个普通函数,在函数里依然有一些分支判断。但是在第一次进入条件分支之后,在函数内部会重写这个函数,重写之后的函数就是我们期望的addEvent函数,在下一次进入addEvent函数的时候,addEvent函数里不再存在条件分支语句:

        <html>
            <body>
              <div id="div1">点我绑定事件</div>
            <script>

            var addEvent = function( elem, type, handler ){
              if ( window.addEventListener ){
                  addEvent = function( elem, type, handler ){
                      elem.addEventListener( type, handler, false );
                  }
              }else if ( window.attachEvent ){
                  addEvent = function( elem, type, handler ){
                      elem.attachEvent( 'on' + type, handler );
                  }
              }

              addEvent( elem, type, handler );
            };
              var div = document.getElementById( 'div1' );

              addEvent( div, 'click', function(){
                  alert (1);
              });

              addEvent( div, 'click', function(){
                  alert (2);
              });

            </script>
            </body>
        </html>