根据《JavaScript语言精粹》了解JavaScript函数

52 阅读11分钟

《JavaScript语言精粹》第四章:函数——JavaScript的精髓与艺术

在《JavaScript语言精粹》第四章的开篇,Douglas Crockford就明确指出:

“JavaScript中最好的特性就是它对函数的实现。它几乎无所不能。”

这一章不仅仅是在讲“如何写函数”,而是在揭示JavaScript这门语言的核心编程范式——函数作为一等公民,是如何渗透在语言的每一个角落,成为构建可靠、可维护、富有表现力代码的基石。

作为JavaScript初学者,我最近深入阅读了《JavaScript语言精粹》第四于对象的章节。函数在JavaScript中无处不在,是构建一切的基础。

4.1 函数对象:函数也是对象

在JavaScript中,函数就是对象。这一点是理解JavaScript函数所有高级特性的起点。

每个函数对象在创建时都会连接到Function.prototype(该原型又连接到Object.prototype),并且附带两个隐藏属性:

  • 函数的上下文
  • 实现函数行为的代码

更重要的是,每个函数都有一个prototype属性,其值是一个包含constructor属性且指向该函数的对象。这种设计为后续的原型继承构造器模式奠定了基础。

由于函数是对象,它们可以:

  • 被赋值给变量或数组元素
  • 作为参数传递
  • 作为返回值
  • 拥有自己的属性和方法
// 函数作为值传递
var add = function(a, b) {
    return a + b;
};

// 函数作为方法
var calculator = {
    add: function(a, b) { return a + b; },
    multiply: function(a, b) { return a * b; }
};

4.2 函数字面量

函数字面量是创建函数对象的主要方式,它包含四个部分。

第一部分,保留字function 告诉JavaScript解释器:接下来的代码是一个函数定义。这是一个语法标记,就像var用于变量声明一样。

第二部分,函数名 是可选的,但这不影响函数的本质。匿名函数在JavaScript中非常常见,特别是在函数式编程和回调模式中。当函数有名字时,这个名字可以在函数内部用于递归调用,也可以被调试工具用于识别函数。但有趣的是,即使函数没有名字,它仍然是一个完整的函数对象,这体现了JavaScript对匿名函数的友好支持。

第三部分,参数列表 定义了函数接收的输入。JavaScript的参数处理非常灵活:参数不需要类型声明,实参和形参的数量可以不匹配。这种灵活性既是优点也是缺点——它让函数接口变得简单,但也增加了运行时错误的风险。

第四部分,函数体 包含了函数要执行的语句。这里有一个重要特性:函数体内部可以访问外部函数的变量,这就是闭包的基础。函数体中的变量和参数构成了函数的局部作用域,这是JavaScript实现信息隐藏和模块化的关键机制。

// 具名函数字面量
var factorial = function factorial(n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1); // 可递归调用
};

// 匿名函数字面量
var greet = function(name) {
    return "Hello, " + name + "!";
};

4.3 调用

函数的调用方式决定了this的绑定值,JavaScript有四种调用模式:

方法调用模式

当一个函数被保存为一个对象的一个属性时,我们称它为方法。当一个方法被调用时,this被绑定到该对象。如果调用表达式包含一个提取属性的动作(.[]),那么它就是被当作一个方法来调用的。

var myObject = {
    value: 0,
    increment: function(inc) {
        this.value += typeof inc === 'number' ? inc : 1;
    }
};

myObject.increment();  // this指向myObject
console.log(myObject.value); // 1

函数调用模式

当一个函数并非一个对象的属性时,那么他就是被当作一个函数来调用的。此模式调用函数时,this被绑定到全局对象

注意:这是一个在语言上设计的缺陷,倘若设计正确,当内部函数被调用时,this应该绑定带为不含念书的this变量。这个设计导致方法不能利用内部函数来帮助它工作,因此内部函数的this被绑定错误的值。所以不能共享该方法对对象的访问权。幸运的是,该方法可以定义一个变量并且把它赋值为this,那么内部函数可以通过那个变量访问到this。我们一般将这个变量约定为that

var sum = add(3, 4); // this指向全局对象

// 解决方法:在方法内部捕获this
myObject.double = function() {
    var that = this; // 捕获this
    
    var helper = function() {
        that.value = add(that.value, that.value);
    };
    
    helper(); // 函数调用,但能访问that
};

构造器调用模式

JavaScript是一门基于原型继承的语言。这意味着对象可以直接从其他对象继承属性。该语 言是无类别的。

这偏离了当今编程语言的主流。当今大多数语言都是基于类的语言。尽管原型继承有着强 大的表现力,但它并不被广泛理解。JavaScript本身对其原型的本质也缺乏信心,所以它提 供了一套和基于类的语言类似的对象构建语法。有类型化语言编程经验的程序员们很少有 愿意接受原型继承的,并且认为借鉴类型化语言的语法模糊了这门语言真实的原型本质。 真是两边都不讨好。

如果在一个函数前面带上 new 来调用,那么将创建一个隐藏连接到该函数的 prototype 成员的新对象,同时 this 将会被绑定到那个新对象上。

var Quo = function(string) {
    this.status = string;
};

Quo.prototype.get_status = function() {
    return this.status;
};

var myQuo = new Quo("confused");
console.log(myQuo.get_status()); // "confused"

Apply调用模式

因为JavaScript 是一门函数式的面向对象编程语言,所以函数可以拥有方法。

apply 方法让我们构建一个参数数组并用其去调用函数。它也允许我们选择 this的值。 apply方法接收两个参数。第一个是将被绑定给 this的值。第二个就是一个参数数组。

var array = [3, 4];
var sum = add.apply(null, array); // sum = 7

var statusObject = { status: 'A-OK' };
var status = Quo.prototype.get_status.apply(statusObject); // 'A-OK'

4.4 参数

JavaScript的函数参数处理非常灵活:

  • 实参与形参数量不匹配不会报错
  • 多出的参数被忽略,缺少的参数为undefined
  • 没有类型检查
  • 所有函数都可访问arguments伪数组
var sum = function() {
    var total = 0;
    for (var i = 0; i < arguments.length; i++) {
        total += arguments[i];
    }
    return total;
};

console.log(sum(4, 8, 15, 16, 23, 42)); // 108

4.5 返回

每个函数调用都会返回一个值:

  • 使用return显式返回值
  • return语句时返回undefined
  • 构造器调用时,如果返回值不是对象,则返回this

4.6 异常

JavaScript使用try-catch机制处理异常:

var add = function(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw {
            name: 'TypeError',
            message: 'add needs numbers'
        };
    }
    return a + b;
};

var try_it = function() {
    try {
        add("seven");
    } catch (e) {
        console.log(e.name + ': ' + e.message); // TypeError: add needs numbers
    }
};

4.7 给类型增加方法

JavaScript允许给基本类型添加方法,这是非常强大的特性

我们可以通过function.prototype增加一个method方法

由于JavaScript并没有单独的整数类型,有时候只提取数字中的整数部分是必要的。我们也可以通过number.prototype添加一个integer方法来改善。

同时缺少一个移除字符串末端空白的方法,可以通过string.trim

// 给Function.prototype添加method方法
Function.prototype.method = function(name, func) {
    this.prototype[name] = func;
    return this;
};

// 给Number添加integer方法
Number.method('integer', function() {
    return Math[this < 0 ? 'ceil' : 'floor'](this);
});

console.log((-10 / 3).integer()); // -3

// 给String添加trim方法
String.method('trim', function() {
    return this.replace(/^\s+|\s+$/g, '');
});

console.log('"' + "   neat   ".trim() + '"'); // "neat"

4.8 递归

递归是一种强大的编程技术,它将问题分解为相似的子问题。在JavaScript中,递归特别适合处理树形结构(如DOM)或数学上递归定义的问题(如斐波那契数列)。

递归函数有两个关键部分:基本情况(base case)和递归情况(recursive case)。基本情况处理最简单的情况并直接返回结果,递归情况则将问题分解并调用自身。

递归函数直接或间接调用自身,适合解决分治类问题:

// 汉诺塔问题
var hanoi = function(disc, src, aux, dst) {
    if (disc > 0) {
        hanoi(disc - 1, src, dst, aux);
        console.log('Move disc ' + disc + ' from ' + src + ' to ' + dst);
        hanoi(disc - 1, aux, src, dst);
    }
};

hanoi(3, 'Src', 'Aux', 'Dst');

4.9 作用域

编程语言中,作用域控制着变量与参数的可见性及生命周期。对程序员来说这是一个重要的帮助,因为它减少了名称冲突,并且提供了自动内存管理。

var foo = function() {
    var a = 3, b = 5;
    
    var bar = function() {
        var b = 7, c = 11;
        // 此时a为3,b为7,c为11
        a += b + c;
        // 此时a为21,b为7,c为11
    };
    
    bar();
    // 此时a为21,b为5
};

4.10 闭包

闭包是内部函数可以访问外部函数的变量,即使外部函数已经返回。

之前,我们构造了一个myobject 对象,它拥有一个 value 属性和一个 increment 方法。假定我们希望保护该值不会被非法更改。 和以对象字面量形式去初始化 myobject 不同,我们通过调用一个函数的形式去初始化myObject ,该函数将返回一个对象字面量。此函数定义了一个 value 变量。该变量对increment 和 getValue 方法总是可用的,但函数的作用域使得它对其他的程序来说是不可见的。

var myObject = function() {
    var value = 0;
    
    return {
        increment: function(inc) {
            value += typeof inc === 'number' ? inc : 1;
        },
        getValue: function() {
            return value;
        }
    };
}();

myObject.increment(2);
console.log(myObject.getValue()); // 2

经典的循环中的闭包问题及解决方案:

// 错误的方式:所有事件处理器都显示i的最终值
var add_the_handlers_bad = function(nodes) {
    var i;
    for (i = 0; i < nodes.length; i++) {
        nodes[i].onclick = function(e) {
            alert(i); // 总是显示nodes.length
        };
    }
};

// 正确的方式:使用闭包捕获每个i的值
var add_the_handlers_good = function(nodes) {
    var i;
    for (i = 0; i < nodes.length; i++) {
        nodes[i].onclick = function(i) {
            return function(e) {
                alert(i);
            };
        }(i);
    }
};

4.11 回调

回调函数使得处理不连续事件成为可能,是异步编程的核心:

// 同步请求(会阻塞)
request = prepare_the_request();
response = send_request_synchronously(request);
display(response);

// 异步请求(更好的方式)
request = prepare_the_request();
send_request_asynchronously(request, function(response) {
    display(response);
});

4.12 模块

模块是一个提供接口却隐藏状态与实现的函数或者对象。

String.method('deentityify', function() {
    var entity = {
        quot: '"',
        lt: '<',
        gt: '>'
    };
    
    return function() {
        return this.replace(/&([^&;]+);/g, 
            function(a, b) {
                var r = entity[b];
                return typeof r === 'string' ? r : a;
            }
        );
    };
}());

console.log('&lt;&quot;&gt;'.deentityify()); // <">

4.13 级联

通过让方法返回this而不是undefined,可以实现优雅的链式调用。有一些方法没有返回值。

例如,一些设置或修改对象的某个状态却不返回任何值的方法就是典型的例子。如果我们让这些方法返回 this 而不是 undefined,就可以启用级联。在一个级联中,我们可以在单独一条的语句中依次调用同一个对象的很多方法。一个启用级联的Ajax类库可能允许我们以这样的形式去编码:

// 假设的Ajax库API
getElement('myBoxDiv')
    .move(350, 150)
    .width(100)
    .height(100)
    .color('red');

4.14 套用

套用允许我们将函数与部分参数结合,产生新函数。

curry 方法通过创建一个保存着原始函数和被套用的参数的闭包来工作。它返回另一个函数,该函数被调用时,会返回调用原始函数的结果,并传递调用curry时的参数加上当前调用的参数的所有参数。它使用 Array 的concat 方法去连接两个参数数组。

Function.method('curry', function() {
    var args = arguments, that = this;
    return function() {
        return that.apply(null, args.concat(arguments));
    };
});

var add1 = add.curry(1);
console.log(add1(6)); // 7

4.15 记忆

函数可以用对象去记住先前操作的结果,从而能避免无谓的运算。这种优化被称为记忆。

记忆通过缓存先前计算结果来优化性能:

var fibonacci = function() {
    var memo = [0, 1];
    var fib = function(n) {
        var result = memo[n];
        if (typeof result !== 'number') {
            result = fib(n - 1) + fib(n - 2);
            memo[n] = result;
        }
        return result;
    };
    return fib;
}();

// 通用的memoizer函数
var memoizer = function(memo, formula) {
    var recur = function(n) {
        var result = memo[n];
        if (typeof result !== 'number') {
            result = formula(recur, n);
            memo[n] = result;
        }
        return result;
    };
    return recur;
};

var fibonacci = memoizer([0, 1], function(recur, n) {
    return recur(n - 1) + recur(n - 2);
});

var factorial = memoizer([1, 1], function(recur, n) {
    return n * recur(n - 1);
});

总结:掌握函数,掌握JavaScript

第四章向我们展示了JavaScript函数的全貌——从基础的对象特性到高级的函数式编程概念。理解这些知识点意味着:

  1. 理解JavaScript的面向对象:函数作为构造器、原型继承的基础
  2. 掌握异步编程:回调函数、闭包的应用
  3. 编写模块化代码:利用闭包实现信息隐藏
  4. 优化性能:记忆、延迟计算等技术
  5. 提升代码表现力:级联、套用等模式让代码更优雅

正如Crockford所言,函数是JavaScript的精华所在。所以一定要认真负责的学好函数。