轻量函数式 JavaScript:七、闭包 vs 对象

多年以前,Anton van Straaten 编写了一个名声显赫而且广为流传的 禅家公案,描绘并挑起了闭包与对象之间一种重要的紧张状态。

庄严的 Qc Na 大师在与他的学生 Anton 一起散步。Anto 希望促成一次与师傅的讨论,他说:“师傅,我听说对象是个非常好的东西 —— 真的吗?” Qc Na 同情地看着他的学生回答道,“笨学生 —— 对象只不过是一种简单的闭包。”

被训斥的 Anton 告别他的师父返回自己的房间,开始有意地学习闭包。他仔细地阅读了整部 “Lamda:终极……” 系列书籍以及其姊妹篇,并且使用一个基于闭包的对象系统实现了一个小的 Scheme 解释器。他学到了很多,希望向他的师父报告自己的进步。

当他再次与 Qc Na 散步时,Anton 试图给师傅一个好印象,说:“师父,经过勤奋的学习,现在我理解了对象确实是简化的闭包。” Qc Na 用他的拐杖打了 Anton 作为回应,他说:“你到底什么时候才能明白?闭包只是简化的对象。” 此时此刻,Anton 茅塞顿开。

Anton van Straaten 6/4/2003

http://people.csail.mit.edu/gregs/ll1-discuss-archive-html/msg03277.html

原版的文章,虽然简短,但是拥有更多关于其起源与动机的背景内容,我强烈建议你阅读这篇文章,来为本章的学习正确地设置你的思维模式。

我见过许多读过这段公案的人都对它的聪明机智表现出一丝假笑,然后并没有改变太多他们的想法就离开了。然而,一个公案的目的(从佛教禅的角度而言)就是刺激读者对其中矛盾的真理上下求索。所以,回头再读一遍。然后再读一遍。

它到底是什么?闭包是简化的对象,或者对象是简化的闭包?或都不是?或都是?难道唯一的重点是闭包和对象在某种意义上是等价的?

而且这与函数式编程有什么关系?拉一把椅子深思片刻。如果你乐意的话,这一章将是一次有趣的绕路远足。

同一轨道

首先,让我们确保当我们谈到闭包和对象时我们都在同一轨道上。显然我们的语境是 JavaScript 如何应对这两种机制,而且具体来说谈到的是简单的函数闭包(参见第二章的“保持作用域”)与简单对象(键值对的集合)。

一个简单的函数闭包:

function outer() {
    var one = 1;
    var two = 2;

    return function inner(){
        return one + two;
    };
}

var three = outer();

three();            // 3

一个简单的对象:

var obj = {
    one: 1,
    two: 2
};

function three(outer) {
    return outer.one + outer.two;
}

three( obj );        // 3

当你提到“闭包”时,很多人都会在脑中唤起许多额外的东西,比如异步回调,甚至是带有封装的模块模式和信息隐藏。相似地,“对象”会把类带到思维中,this、原型、以及一大堆其他的工具和模式。

随着我们向前迈进,我们将小心地解说这种重要外部语境的一部分,但就目前来说,只要抓住“闭包”与“对象”的最简单的解释就好 —— 这将使我们的探索少一些困惑。

看起来很像

闭包与对象是如何联系在一起的,这可能不太明显。所以让我们首先来探索一下它们的相似性。

为了框定这次讨论,让我简要地断言两件事:

  1. 一个没有闭包的编程语言可以使用对象来模拟闭包。
  2. 一个没有对象的编程语言可以使用闭包来模拟对象。

换句话说,我们可以认为闭包和对象是同一种东西的两种不同表现形式。

状态

考虑这段从上面引用的代码:

function outer() {
    var one = 1;
    var two = 2;

    return function inner(){
        return one + two;
    };
}

var obj = {
    one: 1,
    two: 2
};

inner() 和对象 obj 封闭的两个作用域都包含两个状态元素:带有值 1one,和带有值 2two。在语法上和机制上,这些状态的表现形式是不同的。而在概念上,它们其实十分相似。

事实上,将一个对象表示为一个闭包,或者将一个闭包表示为一个对象是相当直接了当的。去吧,自己试一下:

var point = {
    x: 10,
    y: 12,
    z: 14
};

你有没有想到过这样的东西?

function outer() {
    var x = 10;
    var y = 12;
    var z = 14;

    return function inner(){
        return [x,y,z];
    }
};

var point = outer();

注意: inner() 函数在每次被调用时创建并返回一个新数组(也就是一个对象!)。这是因为 JS 没有给我们任何 return 多个值的能力,除非将它们封装在一个对象中。从技术上讲,这并不违背我们的闭包做对象的任务,因为这只是一个暴露/传送值的实现细节;状态追踪本身依然是无对象的。使用 ES6+ 的数组解构,我们可以在另一侧声明式地忽略这个临时中间数组:var [x,y,z] = point()。从一个开发者的人体工程学角度来说,这些值被分离地存储而且是通过闭包而非对象追踪的。

要是我们有一些嵌套的对象呢?

var person = {
    name: "Kyle Simpson",
    address: {
        street: "123 Easy St",
        city: "JS'ville",
        state: "ES"
    }
};

我们可以使用嵌套的闭包来表示同种状态:

function outer() {
    var name = "Kyle Simpson";
    return middle();

    // ********************

    function middle() {
        var street = "123 Easy St";
        var city = "JS'ville";
        var state = "ES";

        return function inner(){
            return [name,street,city,state];
        };
    }
}

var person = outer();

让我们实践一下从另一个方向走,由闭包到对象:

function point(x1,y1) {
    return function distFromPoint(x2,y2){
        return Math.sqrt(
            Math.pow( x2 - x1, 2 ) +
            Math.pow( y2 - y1, 2 )
        );
    };
}

var pointDistance = point( 1, 1 );

pointDistance( 4, 5 );        // 5

distFromPoint(..) 闭合着 x1y1,但我们可以将这些值作为一个对象明确地传递:

function pointDistance(point,x2,y2) {
    return Math.sqrt(
        Math.pow( x2 - point.x1, 2 ) +
        Math.pow( y2 - point.y1, 2 )
    );
};

pointDistance(
    { x1: 1, y1: 1 },
    4,    // x2
    5    // y2
);
// 5

point 状态对象被明确地传入,取代了隐含地持有这个状态的闭包。

行为也是!

对象和闭包不仅代表表达状态集合的方式,它们还可以通过函数/方法包含行为。将数据与它的行为打包有一个炫酷的名字:封装。

考虑如下代码:

function person(name,age) {
    return happyBirthday(){
        age++;
        console.log(
            "Happy " + age + "th Birthday, " + name + "!"
        );
    }
}

var birthdayBoy = person( "Kyle", 36 );

birthdayBoy();            // Happy 37th Birthday, Kyle!

内部函数 happyBirthday() 闭合着 nameage,所以其中的功能和状态一起保留了下来。

我们可以使用 this 与一个对象的绑定取得相同的能力:

var birthdayBoy = {
    name: "Kyle",
    age: 36,
    happyBirthday() {
        this.age++;
        console.log(
            "Happy " + this.age + "th Birthday, " + this.name + "!"
        );
    }
};

birthdayBoy.happyBirthday();
// Happy 37th Birthday, Kyle!

我们仍然使用 happyBirthday() 函数来表达状态数据的封装,但使用一个对象而不是闭包。而且我们不必向一个函数明确地传入一个对象(比如前一个例子);JavaScript 的 this 绑定很容易地创建了一个隐含绑定。

另一种分析这种关系的方式是:一个闭包将一个函数与一组状态联系起来,而一个持有相同状态的对象可以有任意多个操作这些状态的函数。

事实上,你甚至可以使用一个闭包作为接口暴露多个方法。考虑一个带有两个方法的传统对象:

var person = {
    firstName: "Kyle",
    lastName: "Simpson",
    first() {
        return this.firstName;
    },
    last()
        return this.lastName;
    }
}

person.first() + " " + person.last();
// Kyle Simpson

仅使用闭包而非对象,我们可以将这个程序表示为:

function createPerson(firstName,lastName) {
    return API;

    // ********************

    function API(methodName) {
        switch (methodName) {
            case "first":
                return first();
                break;
            case "last":
                return last();
                break;
        };
    }

    function first() {
        return firstName;
    }

    function last() {
        return lastName;
    }
}

var person = createPerson( "Kyle", "Simpson" );

person( "first" ) + " " + person( "last" );
// Kyle Simpson

虽然这些程序在人体工程学上的观感不同,但它们实际上只是相同程序行为的不同种类实现。

(不)可变性

许多人一开始认为闭包和对象在可变性方面表现不同;闭包可以防止外部改变而对象不能。但是,事实表明,两种形式具有完全相同的可变性行为。

这是因为我们关心的,正如第六章中所讨论的,是 的可变性,而它是值本身的性质,与它在哪里以及如何被赋值无关。

function outer() {
    var x = 1;
    var y = [2,3];

    return function inner(){
        return [ x, y[0], y[1] ];
    };
}

var xyPublic = {
    x: 1,
    y: [2,3]
};

存储在 outer() 内部的词法变量 x 中的值是不可变的 —— 记住,2 这样的基本类型根据定义就是不可变的。但是被 y 引用的值,一个数组,绝对是可变的。这对 xyPublic 上的属性 xy 来说是完全一样的。

我们可以佐证对象与闭包和不可变性无关:指出 y 本身就是一个数组,如此我们需要将这个例子进一步分解:

function outer() {
    var x = 1;
    return middle();

    // ********************

    function middle() {
        var y0 = 2;
        var y1 = 3;

        return function inner(){
            return [ x, y0, y1 ];
        };
    }
}

var xyPublic = {
    x: 1,
    y: {
        0: 2,
        1: 3
    }
};

如果你将它考虑为 “乌龟(也就是对象)背地球”,那么在最底下一层,所有的状态数据都是基本类型,而所有的基本类型都是不可变的。

不管你是用嵌套的对象表示状态,还是用嵌套的闭包表示状态,被持有的值都是不可变的。

同构

如今 “同构” 这个词经常被扔到 JavaScript 旁边,它通常用来指代可以在服务器与浏览器中使用/共享的代码。一段时间以前我写过一篇博客,声称对 “同构” 这一词的这种用法是一种捏造,它实际上有一种明确和重要的含义被掩盖了。

同构意味着什么?好吧,我们可以从数学上,或社会学上,或生物学上讨论它。同构的一般概念是,你有两个东西,它们虽然不同但在结构上有相似之处。

在所有这些用法中,同构与等价以这样的方式被区分开:如果两个值在所有的方面都完全相等,那么它们就是等价的。但如果它们表现不同,却仍然拥有 1 对 1 的、双向的映射关系,那么它们就是同构的。

换言之,如果你能够从 A 映射(转换)到 B 而后又可以从用反向的映射从 B 走回到 A,那么 A 和 B 就是同构的。

回忆一下第二章的 “数学简忆”,我们讨论了函数的数学定义 —— 输入与输出之间的映射。我们指出这在技术上被称为一种态射。同构是双射(也就是两个方向的)的一种特殊情况,它不仅要求映射必须能够在两个方向上进行,而且要求这两种形式在行为上也完全一样。

把对数字的思考放在一边,让我们将同构联系到代码上。再次引用我的博客:

如果 JS 中存在同构这样的东西,它将会是什么样子?好吧,它可能是这样:你拥有这样一套 JS 代码,它可以被转换为另一套 JS 代码,而且(重要的是)如果你想这么做的话,你可将后者转换回前者。

正如我们早先使用闭包即对象与对象即闭包的例子所主张的,这些表现形式可以从两个方向转换。以这种角度来说,它们互相是同构的。

简而言之,闭包和对象是状态(以及与之关联的功能)的同构表现形式。

当一下次你听到某些人说 “X 与 Y 是同构的”,那么他们的意思是,“X 和 Y 可以在两个方向上从一者转换为另一者,并保持相同的行为。”

底层

那么,从我们可以编写的代码的角度讲,我们可以认为对象是闭包的一种同构表现形式。但我们还可以发现,一个闭包系统实际上可能 —— 而且很可能 —— 用对象来实现!

这样考虑一下:在下面的代码中,JS 如何在 outer() 已经运行过后,为了 inner() 保持变量 x 的引用而追踪它?

function outer() {
    var x = 1;

    return function inner(){
        return x;
    };
}

我们可以想象,outer() 的作用域 —— 所有变量被定义的集合 —— 是用一个带有属性的对象实现的。那么,从概念上将,在内存的某处,有这样一些东西:

scopeOfOuter = {
    x: 1
};

然后对于函数 inner() 来说,在它被创建时,它得到一个称为 scopeOfInner 的(空的)作用域对象,这个作用域对象通过它的 [[Prototype]] 链接到 scopeOfOuter 对象上,有些像这样:

scopeOfInner = {};
Object.setPrototypeOf( scopeOfInner, scopeOfOuter );

然后,在 inner() 内部,当它引用词法变量 x 时,实际上更像是这样:

return scopeOfInner.x;

scopeOfInner 没有属性 x,但它 [[Prototype]] 链接着拥有属性 xscopeOfOuter。通过原型委托访问 scopeOfOuter.x 的结果,于是值 1 被返回了。

以这种方式,我们可以看到为什么 outer() 即使是在运行完成之后它的作用域也会被(通过闭包)保留下来:因为对象 scopeOfInner 链接着对象 scopeOfOuter,因此这可以使这个对象和它的属性完整地保留。

这都是概念上的。我没说 JS 引擎使用了对象和原型。但这 可以 相似地工作是完全说得通的。

许多语言确实是通过对象实现闭包的。而另一些语言以闭包的形式实现对象。但至于它们如何工作,我们还是让读者发挥他们的想象力吧。

分道扬镳

那么闭包和对象是等价的,对吧?不完全是。我打赌它们要比你在读这一章之前看起来相似多了,但它们依然有重要的不同之处。

这些不同不应视为弱点或用法上的争议;那是错误的视角。它们应当被视为使其中一者比另一者具有更适于(而且更合理!)某种特定任务的特性或优势。

结构可变性

从概念上讲,一个闭包的结构是不可变的。

换言之,你绝不可能向一个闭包添加或移除状态。闭包是一种变量被声明的位置(在编写/编译时固定)的性质,而且对任何运行时条件都不敏感 —— 当然,这假定你使用 strict 模式而且/或者没有使用 eval(..) 这样的东西作弊!

注意: JS 引擎在技术上可以加工一个闭包来剔除任何在它作用域中的不再被使用的变量,但这对于开发者来说是一个透明的高级优化。无论引擎实际上是否会做这些种类的优化,我想对于开发者来说最安全的做法是假定闭包是以作用域为单位的,而非以变量为单位的。如果你不想让它存留下来,就不要闭包它!

然而,对象默认是相当可变的。只要这个对象还没有被冻结(Object.freeze(..)),你就可以*地向一个对象添加或移除(delete)属性/下标。

能够根据程序中运行时的条件来追踪更多(或更少)的状态,可能是代码的一种优势。

例如,让我们想象一个游戏中对击键事件的追踪。几乎可以肯定,你想要使用一个数组来这样做:

function trackEvent(evt,keypresses = []) {
    return keypresses.concat( evt );
}

var keypresses = trackEvent( newEvent1 );

keypresses = trackEvent( newEvent2, keypresses );

注意: 你有没有发现,为什么我使用 concat(..) 而不是直接向 keypressespush(..)?因为在 FP 中,我们总是想将数组视为一种不可变 —— 可以被重新创建并添加新元素 —— 的数据结构,而不是直接被改变的。我们用了一个明确的重新复制将副作用的恶果替换掉了(稍后有更多关于这一点的内容)。

虽然我们没有改变数组的结构,但如果我们想的话就可以。待会儿会详细说明这一点。

但数组并不是追踪不断增长的 evt 对象 “列表” 的唯一方式。我们可以使用闭包:

function trackEvent(evt,keypresses = () => []) {
    return function newKeypresses() {
        return [ ...keypresses(), evt ];
    };
}

var keypresses = trackEvent( newEvent1 );

keypresses = trackEvent( newEvent2, keypresses );

你发现这里发生了什么吗?

每当我们向 “列表” 中添加一个新事件,我们就在既存的 keypresses() 函数(闭包) —— 她持有当前的 evt 对象 —— 周围创建了一个新的闭包。当我们调用 keypresses() 函数时,它将依次调用所有嵌套着的函数,建立起一个所有分别被闭包的 evt 对象的中间数组。同样,闭包是追踪所有这些状态的机制;你看到的数组只是为了从一个函数中返回多个值而出现的一个实现细节。

那么哪一个适合我们的任务?不出意料地,数组的方式可能要合适得多。闭包在结构上的不可变性意味着我们唯一的选择是在它之上包裹更多的闭包。对象默认就是可扩展的,所我们只要按需要加长数组即可。

顺带一提,虽然我将这种结构上的(不)可变性作为闭包和对象间的一种明显的不同,但是我们将对象作为一个不可变的值来使用的方式实际上更加像是一种相似性。

为每次数组的递增创建一个新数组(通过 concat(..))就是讲数组视为结构上不可变的,这与闭包是结构上不可变的设计初衷在概念上是平行的。

私有性

在分析闭包 vs 对象时,你可能想到的第一个不同就是闭包通过嵌套的词法作用域提供了状态的“私有性”,而对象将所有的东西都作为公共属性暴露出来。这样的私有性有一个炫酷的名字:信息隐藏。

考虑一下词法闭包隐藏:

function outer() {
    var x = 1;

    return function inner(){
        return x;
    };
}

var xHidden = outer();

xHidden();            // 1

现在是公有的相同状态:

var xPublic = {
    x: 1
};

xPublic.x;            // 1

对于一般的软件工程原理来说这里有一些明显的不同 —— 考虑到抽象,带有公共和私有 API 的模块模式,等等 —— 但是让我们将我们的讨论限定在 FP 的角度之上;毕竟,这是一本关于函数式编程的书!

可见性

隐藏信息的能力看起来似乎是一种人们渴望的状态追踪的特性,但是我相信 FP 程序员们可能会持反对意见。

将状态作为一个对象上的公共属性进行管理的一个好处是,枚举(并迭代!)状态中所有的数据更简单。想象你想要处理每一个击键事件(早先的一个例子)来将它存入数据库,使用这样一个工具:

function recordKeypress(keypressEvt) {
    // 数据库工具
    DB.store( "keypress-events", keypressEvt );
}

如果你已经拥有了一个数组 —— 一个带有数字命名属性的对象 —— 那么使用一个 JS 内建的数组工具 forEach(..) 完成这个任务就非常直接了当:

keypresses.forEach( recordKeypress );

但是,如果击键的列表被隐藏在闭包中的话,你就不得不在闭包的公共 API 上暴露一个工具,并使它拥有访问隐藏数据的特权。

例如,我们可以给闭包的 keypresses 示例一个它自己的 forEach,就像数组拥有的内建函数一样:

function trackEvent(
    evt,
    keypresses = {
        list() { return []; },
        forEach() {}
    }
) {
    return {
        list() {
            return [ ...keypresses.list(), evt ];
        },
        forEach(fn) {
            keypresses.forEach( fn );
            fn( evt );
        }
    };
}

// ..

keypresses.list();        // [ evt, evt, .. ]

keypresses.forEach( recordKeypress );

一对象的状态数据的可见性使得它使用起来更直接,而闭包隐晦的状态使我们不得不做更多的工作来处理它。

改变控制

如果词法变量 x 隐藏在一个闭包中,那么唯一能够对它进行重新赋值的代码也一定在这个闭包中;从外部修改 x 是不可能的。

正如我们在第六章中看到的,仅这一点就改善了代码的可读性,它减小了读者为了判定一个已知变量的行为而必须考虑的代码的表面积。

词法上重新赋值的局部接近性是我不觉得 const 是一个有用特性的一大原因。作用域(因此闭包也是)一般来说应当都很小,这意味着仅有几行代码可能会影响到重新赋值。在上面的 outer() 中,我们可以很快地检视并看到没有代码对 x 进行重新赋值,所以对于一切目的和意图来说它都是一个常数。

这种保证及大地增强了我们在函数的纯粹性上的信心。

另一方面,xPublic.x 是一个公共属性,程序中任何得到 xPublic 引用的部分都默认地有能力将 xPublic.x 重新赋值为其他的某些值。要考虑的代码行数可要多多了!

这就是为什么在第六章中,我们看到 Object.freeze(..) 以一种简单粗暴的方式将一个对象的所有属性都设置为只读(writable: false),这样一来它们就不会不可预知地被重新赋值了。

不幸的是,Object.freeze(..) 会冻结所有属性而且不可逆转。

使用闭包,你让一些代码拥有改变的特权,而程序的其余部分依然受限。但你冻结一个对象时,程序中没有任何部分能够进行重新赋值。另外,一旦一个对象被冻结,它就不能再被解冻,于是它的属性会在程序运行期间一直保持只读状态。

在那些我想允许重新赋值但限制它影响范围的地方,闭包就是一种比对象更加方便而且灵活的方式。在我想要禁止重新赋值的地方,一个冻结的对象要比在我的函数中到处重复 const 声明方便多了。

许多 FP 程序员对重新赋值采取了强硬的立场:它就不应当被使用。他们倾向于使用 const 将所有闭包变量都成为只读,而且他们使用 Object.freeze(..) 或者完全不可变的数据结构来防止属性被重新赋值。另外,他们还会尽可能地减少被明确声明/追踪的属性的数量,使用值的传送 —— 函数链,将 return 值作为参数传递,等等 —— 来取代值的临时存储。

这本书讲的是 JavaScript 的“轻量函数式”编程,而这就是我与 FP 的核心人群意见相左的情况之一。

我认为变量的重新赋值可以十分有用,而且,如果使用得当,它的明确性相当易读。而且从经验上讲,当你在调试中插入 debugger 或断点,或者一个监视表达式的时候,将会更容易。

状态克隆

正如我们在第六章中学到的,防止副作用损害我们代码的可预见性的最佳方法之一,就是确保我们将所有状态值都视为不可变的,而不管它们实际上是否真的是不可变(被冻结)的。

如果你没有在使用一个专门为此建造的、提供了精巧的不可变数据结构的库,那么最简单的方法也够了:在每次改变你的对象/数组之前复制它们。

数组很容易浅克隆:使用 slice() 方法就行:

var a = [ 1, 2, 3 ];

var b = a.slice();
b.push( 4 );

a;            // [1,2,3]
b;            // [1,2,3,4]

对象也可以相对容易地进行浅克隆:

var o = {
    x: 1,
    y: 2
};

// 在 ES2017+ 中,使用对象扩散操作:
var p = { ...o };
p.y = 3;

// 在 ES2015+ 中:
var p = Object.assign( {}, o );
p.y = 3;

如果在一个对象/数组中的值本身就是非基本类型(对象/数组),那么为了进行深度克隆,你就必须手动遍历并克隆每一个被嵌套的对象。否则,你会得到那些字对象的共享引用的拷贝,而这很可能会在你的程序逻辑中造成灾难。

你有没有注意到,这种克隆之所以可能,仅仅是由于所有这些状态值都可见,并因此可以很容易拷贝?那么包装在一个闭包中的一组状态呢?你如何拷贝那些状态?

那可麻烦多了。事实上,你不得不做一些与我们之前自定义的 forEach API 方法相似的事情:在闭包的每一层内部都提供一个有权抽取/拷贝隐藏值的函数,一路创建新的等价闭包。

即便这在理论上是可能的 —— 给读者的另一个练习! —— 但与你对任何真实的程序所作出的可能的调整相比,它也远不切实际。

当对象用来表示我们想要克隆的状态时,它具有明显的好处。

性能

一个对象可能优于闭包的原因,从实现的角度讲,是在 JavaScript 中对象在内存甚至计算的意义上更轻量。

但将之作为一个一般性的结论要小心:在你无视闭包并转向基于对象的状态追踪时可能会得到一些性能的增益,但在你能对对象所做的事情中,有相当一部分可以抹除这些增益。

让我们使用两种实现考虑同一个场景。首先,闭包风格的实现:

function StudentRecord(name,major,gpa) {
    return function printStudent(){
        return `${name}, Major: ${major}, GPA: ${gpa.toFixed(1)}`;
    };
}

var student = StudentRecord( "Kyle Simpson", "kyle@some.tld", "CS", 4 );

// 稍后

student();
// Kyle Simpson, Major: CS, GPA: 4.0

内部函数 printStudent() 闭包着三个变量 namemajor、和 gpa。无论我们在何处传送一个指向这个函数的引用,它都会维护这个状态 —— 在这个例子中我们称之为 student()

现在轮到对象(和 this)的方式了:

function StudentRecord(){
    return `${this.name}, Major: ${this.major}, GPA: ${this.gpa.toFixed(1)}`;
}

var student = StudentRecord.bind( {
    name: "Kyle Simpson",
    major: "CS",
    gpa: 4
} );

// 稍后

student();
// Kyle Simpson, Major: CS, GPA: 4.0

student() 函数 —— 技术上成为一个“被绑定函数” —— 拥有一个硬绑定的 this 引用,它指向我们传入的对象字面量,这样稍后对 student() 的调用将会使用这个对象作为 this,因此可以访问它所封装的状态。

这两种实现都有相同的结果:一个保留着状态的函数。那么性能呢?会有什么不同?

注意: 准确地、可操作地判断一段 JS 代码的性能是一件非常棘手的事情。我们不会在此深入所有的细节,但我强烈建议你阅读“你不懂 JS:异步与性能”一书,特别是第六章“基准分析与调优”,来了解更多细节。

如果你在编写一个库,它创建一个带有函数的状态 —— 要么是一个代码段中对 StudentRecord(..) 的调用,要么是第二个代码段中对 StudentRecord.bind(..) 的调用 —— 你最关心的很可能是它们两个如何工作。检视它们的代码,我们可以发现前者不得不每次创建一个新的函数表达式。而第二个使用了 bind(..),这由于它的隐晦而不那么明显。

考虑 bind(..) 在底层如何工作的一种方式是,它在函数之上创建了一个闭包,就像这样:

function bind(orinFn,thisObj) {
    return function boundFn(...args) {
        return origFn.apply( thisObj, args );
    };
}

var student = bind( StudentRecord, { name: "Kyle.." } );

以这种方式,看起来我们这种场景的两种实现都创建了闭包,因此它们的性能很可能是相同的。

然而,内建的 bind(..) 工具不必真的创建闭包来完成这个任务。它只是创建一个函数并手动地将它内部的 this 设置为指定的对象。这潜在地是一种比我们自己做的闭包更高效的操作。

我们在这里讨论的这种性能提升在个别的操作中的影响微乎其微。但如果你的库的关键路径在成百上千次,或更多地重复这件事,那么这种提升的效果就会很快累加起来。许多的库 —— 例如 Bluebird 就是一例 —— 都正是由于这个原因,最终通过移除闭包而使用对象来进行了优化。

在库之外的用例当中,带有自己函数的状态通常只在一个应用程序的关键路径上相对少地出现几次。对比之下,函数 + 状态的用法 —— 在两个代码段中对 student() 的调用 —— 通常更常见。

如果这正是你代码中的某些已知情况,那么你可能应当更多地关心后者的性能与前者的对比。

长久以来被绑定的函数的性能通常都很烂,但是最近它已经被 JS 引擎进行了相当高度的优化。如果你在几年前曾经对这些种类的函数进行过基准分析,那么你在最新的引擎上重复相同的测试的话,就完全有可能得到不同的结果。

如今,一个被绑定函数性能最差也能与它闭包函数的等价物相同。所以这是另一个首选对象而非闭包的理由。

我想要重申:这些性能上的观测不是绝对的,而且对于一个已知场景判定什么对它最合适是非常复杂的。不要只是随便地使用一些道听途说的,或者你曾将在以前的项目中见过的东西。要仔细地检查对象或闭包是否能恰当、高效地完成你当前的任务。

总结

这一章的真理是无法付诸笔头的。你必须阅读这一章来找出它的真理。

上一篇:轻量函数式 JavaScript:五、降低副作用


下一篇:轻量函数式 JavaScript:九、递归