65.9K
CodeProject 正在变化。 阅读更多。
Home

安全工厂模式 - JavaScript 中的私有实例状态

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (11投票s)

2010年12月14日

CPOL

24分钟阅读

viewsIcon

81220

downloadIcon

186

在任何 JavaScript 对象中启用私有状态。

引言

(几年后,我将该模式的实现发布在这个 github 项目中)

JavaScript 对象与字典非常相似。它们具有由名称标识的属性,这些属性具有关联的值,可以是任何 JavaScript 类型。对象的属性可以随时随地被任何代码更改、添加或删除,只要您拥有对该对象的引用。这种松散的对象模型是 JavaScript 的众多优点之一。以下 JavaScript 代码示例说明了对普通对象的这些操作

var obj = {}; // <=> new Object();
 
// Add 'prop1'
obj.prop1 = 1;
 
// Add 'prop2'
obj.prop2 = 2;
 
// Delete 'prop1'
delete obj.prop1;
 
// Change value of 'prop2'
obj.prop2 = 3;

这个狂野而动态的世界也意味着,对于给定对象的属性中存储的内容,没有任何确定性。

虽然 JavaScript 代码小巧精美,但这是一个美好的世界。但随着它的日益壮大、结构化和复杂化,缺乏确定性成为代码生存的最大威胁。

ECMAScript 5 能带来确定性吗?

最新的 ECMAScript 标准,版本 5,已经包含解决此问题的特性。令人高兴的是,大多数主流浏览器(有些仍在测试中)已经提供了对这些特性的部分支持(参见ECMAScript 兼容性表)。最大的改进之一是能够将属性定义为常量——这允许创建不可变对象,即其属性无法删除或更改其值的对象。这太棒了!

然而,如果所需的确定性和不变量适用于可变属性,即那些在其生命周期内需要更改以满足对象可变需求的属性,则常量属性就无用武之地。为了保持这些变化属性的确定性和不变量,将其私有化是唯一的途径——否则,所有为编写正确代码所做的努力都可能因为属性的公开暴露而付诸东流。

在这方面,ECMAScript 5 为 JavaScript 带来了另一个伟大的特性:*访问器属性*。它们看起来和使用起来就像普通属性一样。但与方法一样,它们实际上只是纯粹状态的门面。一对 get 和 set 方法定义了一个访问器属性,并控制实际上存储在对象*值属性*上的内容。以下示例展示了支持定义访问器属性的新*对象字面量语法*

var region = {
    _inside: 0,
    
    get inside(){
        return this._inside;
    },
    
    set inside(value){
        if(this._inside === 0){
           throw new Error("there's no one inside to leave"); 
        }   
 
        this._inside = value;
    }
};
 
alert(region.inside === 0);
region.inside = 1;
alert(region.inside === 1);

这也太棒了!正如你所看到的,你可以更好地控制存储在对象中的内容,并且使用更合适的语法。然而,请注意,`region` 对象的 `_inside` 属性仍然是公共的,因此容易包含无效值 `"foo"`。

那么,从现在到未来某个 JavaScript 版本发布并被采用,这个版本将为我们带来具有私有属性的基于类的对象模型,JavaScript 代码是否注定要保持小巧或变得混乱?

我想是的……除非……你发现我在这篇文章中提出的模式是解决问题的方法。它使用 JavaScript *闭包*及其私有特性为任何 JavaScript 对象提供私有状态。私有状态与对象一起保存在公共属性中,我称之为“保险箱”。但只有持有其“密钥”的特权代码才能打开它们。

读者应该知道,对于“JavaScript 中的公共与私有状态”问题,还有其他人持有截然不同的观点。特别是在公共状态对代码质量、稳定性、可维护性等方面的实际影响。您可能想看看私有变量是邪恶的,或者我引用的其他一些来源的评论。

下一节将展示如何使用 JavaScript 作用域和闭包创建私有状态。如果您已经熟悉这些概念,可以跳过它,直接进入安全工厂模式一节。

文本区域、作用域、闭包和隐私

幸运的是,JavaScript 中并非所有内容都是公共的。变量名、函数和函数参数只存在于代码的受限文本(或词法)区域内。这些名称不能从其封闭区域之外编写的代码中引用。文本区域及其所有声明的标识符也称为*作用域*。

作用域类型

JavaScript 中有三种类型的作用域:全局作用域、函数作用域和对象作用域。

  • 全局作用域对应于代码的顶层文本区域,即脚本文件。它包括所有在顶层(而不是函数或 `with` 语句中)声明的变量和函数。
  • 函数作用域对应于函数声明的文本区域,不包括嵌套函数声明和“with”作用域。它包括所有参数名、所有变量和所有在那里声明的命名函数。
  • 对象作用域,或 `with` 作用域,对应于 `with` 语句内的文本区域。它允许将给定的 JavaScript 对象“置于作用域内”:它的属性成为可独立解析的标识符(没有点),就像它们是变量一样。

以下代码显示了每个示例

// global variable
var author = {
    firstName: "Duarte",
    lastName:  "Leão"
};
 
// global function (a singleton)
function Outer(value){
    var offset = 1;
    
    // Local (nested/inner) function
    // One is created per execution of Outer
    function Inner(){
        var offset = -1;
        return value + offset;
    }
 
    return Inner;
}
 
// More global variables
var inner1 = Outer(1);
var inner2 = Outer(2);
 
alert(inner1() === 0);
alert(inner2() === 1);
 
with(author){
    alert(firstName === "Duarte");
    alert(inner2() === 1);
}

从嵌套区域到嵌套作用域和作用域链

嵌套的文本区域形成嵌套的作用域。嵌套的作用域形成所谓的*作用域链*(不要与*原型链*混淆),其中每个作用域都有一个父作用域或外部作用域。在给定作用域中,只要外部作用域的标识符不被同名的本地标识符*遮蔽*,就可以访问它们。请注意函数 `Inner` 中的变量 `offset`。它遮蔽了函数 `Outer` 的变量 `offset`,使得后者在函数 `Inner` 的代码中无法访问。全局作用域是任何作用域链的最外层作用域。

从函数的移动性到执行作用域的生命周期

我目前为止所呈现的是一个静态的、文本作用域世界的景象。如果 JavaScript 函数不是对象,那么它就会一直保持这样。实际上,您可以将函数存储在一个变量中,然后将其传递,作为函数调用的参数,作为函数调用的返回值,或者作为对象属性的值。稍后,该函数可能由不知道它具体做什么或如何做的代码执行。代码和数据之间的这种混杂可以在前面的示例中观察到。函数 `Inner` 的一个新实例在函数 `Outer` 的每次执行中创建并返回。第一个返回的实例存储在全局变量 `inner1` 中,第二个存储在全局变量 `inner2` 中。稍后,变量 `inner2` 中的函数被调用两次。这是 JavaScript 的另一个优点:*代码和数据共存*。

代码以函数对象形式(可以随时随地被任何代码执行)的*可移动性*动摇了我们之前形成的静态作用域图景。我们必须转而将作用域视为真实的*对象*,它们具有表示标识符的属性,并随在其中创建的函数实例一起移动。在我们看来,执行作用域和 JavaScript 对象之间的区别应该只是它们的可访问性。前者不能直接访问,但它们的属性可以通过裸标识符访问。后者可以直接访问,但访问它们的属性需要限定标识符 (`object.property`)。请注意,由于它们的可移动性,*作用域*现在被称为*执行作用域*。执行作用域与在其中创建的函数一样长久。

像 JavaScript 引擎一样思考

让我们重新审视前面的例子,并尝试在这个模型的视角下理解它是如何工作的

SafeFactoryPattern/TheScopeChain.png

  1. 全局作用域在其他任何事情之前由脚本引擎初始化。我们将其命名为 *S0*。在初始化时,会为每个顶层变量和命名函数创建一个标识符。变量标识符都初始化为 `undefined` 的特殊值,而命名函数标识符则初始化为每个函数的新实例。
  2. 在这个例子中,为变量 `author`、`inner1` 和 `inner2` 创建了三个标识符,它们都初始化为 `undefined` 值。此外,在 *S0* 中创建了一个标识符 *Outer*,并将其设置为函数 `Outer` 的一个新创建实例,即 **outer1**。它的“创建者作用域”是 *S0*,并且这个事实与函数一起存储在一个隐藏的系统属性中。执行继续,逐行进行。
  3. 第一行是 `author` 变量的赋值表达式;它创建一个并初始化一个对象,该对象被设置为作用域 *S0* 中标识符 *author* 的值。
  4. 执行在 `var inner1 = Outer(1)` 行继续。
  5. 标识符 *Outer* 在当前作用域 *S0* 中进行评估,结果是函数实例 **outer1**。作为函数,函数调用表达式有效,并且在参数评估之后,值 `1` 作为参数提供。为了支持执行,创建了一个新的函数执行作用域:将其命名为 *S1*。它的父作用域是 **outer1** 的“创建者作用域” *S0*。这个事实存储在 *S1* 中,同样是在一个隐藏的系统属性中。
  6. 作用域 *S1* 的初始化尚未完成。对于函数的每个声明参数,都会创建一个同名标识符,并将其初始化为相应的提供值(如果有),如果没有,则初始化为 `undefined`。然后是 `arguments` 对象的初始化,我将不再赘述。最后,为每个局部变量和命名函数创建并初始化标识符。
  7. 作用域 *S1* 初始化如下:在 *S1* 中创建一个名为 *value* 的标识符,初始值为 `1`;另一个标识符 *offset* 在 *S1* 中创建并初始化为 `undefined`。还有一个标识符 *Inner* 在 *S1* 中创建,其值设置为函数 `Inner` 的一个新创建实例,即 **inner1**。
  8. 执行继续到函数 `Outer` 的第一行。当前作用域是 *S1*。第一行将 `offset` 的值设置为 `1`。接下来未处理的代码行是函数 `Outer` 的最后一行,即 `return` 语句。它引用标识符 *Inner*,该标识符在作用域 *S1* 中立即评估为创建的函数 **inner1**。函数 **inner1** 的执行结束,执行在顶层恢复,将返回值赋给变量 `inner1`。
  9. 重复相同的过程,将 **outer1** 第二次执行的结果赋值给变量 `inner2`——快速向前——一个函数实例 **inner2**,它有一个创建者作用域 *S2*,其中包含一个名为 *value* 且值为 `2` 的标识符,并且其父作用域是 *S0*。
  10. 最后,执行到 `inner1() === 0` 表达式的行。在当前作用域 *S0* 中,标识符 *inner1* 被评估为函数实例 **inner1**。函数调用表达式被评估。
  11. 为了支持 **inner1** 的执行,创建了一个新的函数执行上下文 *S3*。它的父作用域是什么?它是 **inner1** 的“创建者作用域” *S1*,而 *S1* 的父作用域又是 *S0*。
  12. 当前作用域为 *S3*。执行函数 `Inner` 的第一个(也是最后一个)语句。`return` 语句引用标识符 *value*。该标识符在当前作用域中搜索,但没有同名标识符,因此搜索继续到其父作用域 *S1*,在那里找到了名为 *value* 的标识符。它的值为 `1`,然后与局部标识符 *offset* 的值 `-1` 相加并返回。
  13. ...

我们刚刚目睹了*闭包函数*或简称*闭包*的强大功能——一个执行作用域(包含局部变量、参数和命名函数)*可以超越创建它的函数执行而存在*,并随在其中创建的(闭包)函数一起移动。

闭包中的隐私在哪里?

函数实例捕获的执行作用域中包含的状态*仅*在其代码执行时才能访问。拥有函数实例的引用,没有“点”可以带你到该状态,只有函数的代码才能做到。以正在运行的示例为例,代码 `inner1.value` 将评估为 `undefined`,而不是值 `1`,后者只有在 **inner1** 执行时才能访问。

这是 JavaScript 语言提供的唯一真正私有的状态,我们将利用它为其他 JavaScript 对象提供私有状态。如果您想了解更多关于 JavaScript 闭包及其底层执行模型的信息,可以阅读文章JavaScript Closures

已知的隐私和继承模式

有几种已知模式以不同方式使用闭包来实现某种私有状态。我还介绍了一种用于在 JavaScript 中创建类的基本模式。

模块模式

这种模式利用函数来封装一组相关代码——一个模块。模块中只有选定的对象和函数通过导出的方式公开。未导出的对象和函数将只对导出的函数可见,并由所有导出的函数共享。定义模块最常见的方式是使用以下惯用语

var Calculator = (function(){
    
    // A special value that must be kept safe
    var _pi = 3.141592653;
 
    function circleArea(radius){
        return _pi * radius * radius;
    }
 
    // export only desired 'things'
    return {
        circleArea: circleArea
    };
})();

这实际上只是一个*匿名*函数,它被*立即*执行。请注意,未导出的内容仅对导出的函数可见。我个人喜欢使用以下辅助函数来提高模块定义的易读性

function Module(defineModule){
    var exports = {},
        global = this;
 
    defineModule.call(exports);
 
    // Copy properties in 'exports' to global scope
    for(var p in exports){
       if(exports.hasOwnProperty(p)){
          global[p] = exports[p];
       }
    }
}

下一节将展示它的用法。文章JavaScript 模块模式:深入探讨“深入”介绍了模块模式。

基于原型的继承模式

这种模式根本不具备隐私。相反,它是 JavaScript 内在的继承模式,并将作为本文其余部分的比较基础。我将之前展示的 `region` 对象示例改编为一个 JavaScript *基于原型的类*。然后就可以创建它的多个实例。

// Region1 class
Module(function(){
    // The constructor function of the "Region" class
    function Region(){
        this._inside = 0;
    }
 
    Region.prototype.inside = function(){
        return this._inside;
    };
 
    Region.prototype.enter = function(){
        return ++this._inside;
    };
 
    Region.prototype.leave = function(){
        if(this._inside === 0){
            throw new Error("there's no one inside to leave");
        }
        return --this._inside;
    };
    
    this.Region1 = Region;
});

在 JavaScript 中,类仅仅由一个函数表示,该函数扮演其实例的构造函数的角色:该函数被调用,前面加上 `new` 运算符,以初始化类的每个实例。在这种方式调用的函数执行过程中,关键字 `this` 指向正在创建的实例,允许其被初始化。构造函数确保类的所有实例具有相似的初始属性集。

每个 JavaScript 函数都有一个特殊的属性叫做 *prototype*,它包含一个对象,该对象将成为使用该函数作为构造函数创建的对象的原型——模型。每个函数都可以用作构造函数。在原型对象中设置的属性是可见的,通过对相应类的实例执行 *get* 操作,只要在实例本身中没有*设置*同名属性,*遮蔽*继承的属性。原型继承行为用于使类的方法被其所有实例共享。

构造函数内部的方法模式

这种模式是唯一一种(据我所知)能够创建具有真正每个实例私有状态的类。这是通过在其构造函数内部声明 JavaScript 类的方法,作为 `this` 对象的属性来实现的。以下示例展示了如何做到这一点

// Region2 class
Module(function(){
    // The constructor function of the "Region" class
    function Region(){
        var _inside = 0;
 
        this.inside = function(){
            return _inside;
        };
 
        this.enter = function(){
            return ++_inside;
        };
 
        this.leave = function(){
            if(_inside === 0){
                throw new Error("there's no one inside to leave");
            }
            return --_inside;
        };
    }
 
    this.Region2 = Region;
});

在示例中,`_inside` 变量仅对每个实例的方法可访问,这些方法捕获了构造函数调用的执行上下文,针对每个创建的实例。一个区域对象不能访问另一个区域对象的 `_inside` 变量。请注意,这实际上比传统基于类的对象模型中通常更*私有*。对象可以访问同一类的其他实例的属性是常见、有用且合意的。

这种方法的缺点是方法不能在同一类的实例之间共享,这会对内存大小产生负面影响。此外,这种设计使得子类化变得复杂,而子类化通常通过覆盖原型对象的属性来实现。

尽管如此,我认为这种模式非常适合那些旨在成为单例的类,或者只有少数实例且没有子类的类。

您可以阅读 Douglas Crockford 的文章JavaScript 中的私有成员以获取有关此模式的更多信息。他将构造函数内部声明的方法称为*特权方法*,将构造函数中的变量称为*私有变量*。(我只不同意他关于其示例中“that”变量的使用是由于 JavaScript 规范错误而导致的评论。)

安全工厂模式

首次尝试 - 一个保险箱和一把钥匙

我的第一个方法非常简单直观。不出所料,我后来发现有人之前尝试过(参见实例私有、类私有、包和朋友)。在下面的代码中,`createSafe` 函数根据键和秘密值创建“安全”闭包函数

function createSafe(key, value){
 
    function safe(tryKey){
        if(tryKey !== key){ throw new Error("Access denied"); }
        
        return value;
    }
 
    return safe;
}

每个创建的 `safe` 函数都捕获一个秘密值和一个打开密钥。`safe` 函数只有在调用者使用正确的密钥“打开”它时才返回其秘密值。以下示例展示了一个使用此方法的重写的 `Region` 类

// Region3 class
Module(function(){
    // An object provides a unique key
    var _moduleKey = {};
 
    function Region(){
        this._safe = createSafe(_moduleKey, {
            inside: 0
        });
    }
 
    Region.prototype.inside = function(){
        return this._safe(_moduleKey).inside;
    };
 
    Region.prototype.enter = function(){
        var fields = this._safe(_moduleKey);
        return ++fields.inside;
    };
 
    Region.prototype.leave = function(){
        var fields = this._safe(_moduleKey);
        if(fields.inside === 0){
            throw new Error("there's no one inside to leave");
        }
 
        return --fields.inside;
    };
 
    this.Region3 = Region;
});

有几点需要注意

  • 保险箱是在构造函数内部使用私有模块密钥和实例特定的秘密值创建的
  • 方法设置在构造函数的原型中
  • 为了保持保险箱密钥的私密性,需要使用模块
  • 给定的密钥可以打开用它创建的任何保险箱;同一个密钥可以在模块内的许多类中使用
  • 类的实例可以访问同一类的其他实例的私有状态
  • 保险箱设置在每个实例的任意名称的(公共)属性中;然而,其封闭状态是私有的,只有持有其关联密钥的人才能访问
  • 保险箱中保存的值是只读的:它只能在创建保险箱时设置;这是一个设计选项,旨在使保险箱的接口尽可能简单——它只有“获取”操作;可修改的私有状态通过存储一个对象来实现,该对象的属性可以随时更改
  • 为了最小化打开保险箱的额外成本,对私有状态的访问在每个公共方法中只进行一次

那有什么问题呢?

这种方法的缺陷在于,尽管保险箱肯定只会将其秘密透露给提供了正确密钥的调用者,但传递秘密密钥给保险箱的方法能否确定这些保险箱是“真实的”,即它们是由其类创建的?

由于类的公共方法是*公共的*,那么它们就很容易被用来获取模块的秘密密钥。另外请注意,通常情况下,代码对任何人都是可见的,这使得破解变得更容易。以下是获取 `Region3` 模块秘密密钥的方法

function stealSecretKeyOfRegion3(){
    var stolenKey;
 
    function fakeSafe(theKey){
        stolenKey = theKey;
        return {inside: 0};
    }
 
    var impostor = {_safe: fakeSafe};
 
    Region3.prototype.inside.call(impostor);
 
    // Stole the unique "secret" key
    // Use it with any real region
    return stolenKey;
}
 
function breakRegion3(region3){
    var stolenKey = stealSecretKeyOfRegion3();
    return region3._safe(stolenKey);
}
 
var region3 = new Region3();
region3.enter();
 
alert(breakRegion3(region3).inside === 1);

我承认我尝试解决这个问题,但事情很快变得非常复杂。调用方法首先会用一个*挑战*来测试安全函数,其答案只有真实的保险箱才能正确且在合理的时间内给出。通过接收到正确的回应,调用方法将(几乎)确定安全函数是真实的,然后可以将其秘密密钥传递给它以获取秘密值。

这可以做到。事实上,通信系统就是利用这种技术在通信信道通过不安全介质时确认各方身份。这种解决方案的问题在于,即使是最微不足道的挑战,解决起来也比当前问题可接受的时间更长。所以我放弃了。

解决方案 - 一个保险箱、一个开启器和一个安全通信信道

我之前的方法实际上让我理解了这个问题是由什么组成的。解决方案是使用一个安全的通信信道。我们真的不应该通过不安全的介质(安全函数的参数和返回值)传递敏感数据。安全是一个闭包,它持有秘密值。它能否通过请求传递这个值,而不使用函数的返回值或参数来做到这一点?当然可以,我们一直在使用闭包进行带外通信。这是我想出的方法

Module(function(){
 
    function createSafeFactory(){
        var _channel;
 
        function create(value){
 
            function safe(){
                _channel = value;
            }
 
            return safe;
        }
 
        function opener(safe){
            if(_channel != null){ throw new Error("Access denied."); }
 
            safe();
 
            var value;
            return value = _channel,
                   _channel = null,
                   value;
        };
 
        opener.create = create;
        return opener;
    }
    
    this.SafeFactory = {
        create: createSafeFactory
    };
});

我注意到以下几点

  • `SafeFactory` 不是一个类,它只是一个外观对象,暴露了一个 `create` 方法(使其成为*工厂*的原因正是它*创建*其他对象)。
  • `SafeFactory.create` 方法创建了一对闭包,它们共享一个私有 `_channel` 变量,这使得它们能够进行带外通信(不使用函数参数或返回值)。
  • `create` 闭包承担了有缺陷的 `createSafe` 函数的角色。每次调用它时,它都会创建一个持有接收到的秘密 `value` 的 `safe` 闭包。
  • `safe` 闭包也可以访问私有的 `_channel` 变量。
  • `opener` 闭包接收一个 `safe` 并“打开”它。`safe` 闭包被调用并将其秘密值放入 `_channel` 变量中。`opener` 闭包读取放入 `_channel` 变量中的值并返回它。在返回之前注意清除 `_channel` 变量,以确保存储的秘密值不会(内存)泄漏。此过程仅在接收到的 `safe` 闭包是使用关联的 `create` 闭包创建的情况下才有效。
  • 选择将 `opener` 方法作为安全工厂的表示返回,是为了最小化解决方案的成本,即创建的对象数量和执行速度。如果返回 `SafeFactory` 类的一个实例,将创建一个额外的对象,并且频繁访问 `opener` 方法将需要访问该对象的一个属性。
  • 不再需要秘密密钥。相反,`opener` 闭包扮演*密钥*的角色。请注意,任何可以访问 `opener` 闭包的代码都能够打开由关联 `create` 闭包创建的安全闭包。因此,仍然需要一个模块来保持 `opener` 闭包的私密性。

减轻可能的攻击

如果一个 `safe` 闭包不是由正确的 `opener` 闭包调用,而是由一个试图窃取秘密值的“攻击者”调用,那么最糟糕的情况就是安全闭包的秘密值被放置在创建工厂的 `_channel` 变量中。这实际上阻止了秘密值对象被垃圾回收。但如果只有相应的 `opener` 闭包才能访问共享变量的值,这种“攻击”的意义何在?

为了减轻“攻击”,在这种攻击中,如果传递了伪造的 `safe` 函数,`_channel` 变量中停滞的值可能会返回给调用者,`opener` 方法的第一行会验证 `_channel` 变量的值是否为 `null`。

安全工厂模式中的闭包和作用域

下图显示了安全工厂模式创建的闭包及其创建者作用域。请注意 `_channel` 变量,它是由 `createSafeFactory` 函数的执行创建的。在图中,创建执行作用域 *S1* 的 **csf1** 的执行创建了闭包 **create1** 和 **opener1**,它们共享私有 `_channel` 变量。每次调用 **create1** 闭包时,都会创建一个新的安全闭包并返回。每个安全闭包都可以访问 `value` 参数以及私有 `_channel` 变量。

SafeFactoryPattern/SafeFactoryPatternClosures.png

安全区域类

以下代码显示了重写后的 `Region` 类,它使用了安全工厂模式

// Region4 class
Module(function(){
    var _safeOpener = SafeFactory.create();
 
    function Region(){
        this._safe = _safeOpener.create({
            inside: 0
        });
    }
 
    Region.prototype.inside = function(){
        return _safeOpener(this._safe).inside;
    };
 
    Region.prototype.enter = function(){
        var fields = _safeOpener(this._safe);
        return ++fields.inside;
    };
 
    Region.prototype.leave = function(){
       var fields = _safeOpener(this._safe);
       if(fields.inside === 0){
           throw new Error("there's no one inside to leave");
       }
 
       return --fields.inside;
    };
 
    this.Region4 = Region;
});

请注意,现在,不再是秘密模块密钥,而是一个秘密模块开启器闭包。任何可以访问开启器闭包的代码都能够打开它创建的安全闭包,因此仍然需要一个模块来保持开启器闭包的私密性。

安全属性模式

安全工厂模式没有解决的一个问题是存储保险箱的属性的名称选择。在大多数情况下,您甚至可能不关心使用的名称,因为每个类每个实例只会有一个保险箱属性。然而,继承呢?如果使用保险箱属性的类被子类化,可能在不同的模块中,并且该子类也想拥有自己的保险箱属性,那么用于存储保险箱的属性名称就变得相关:它们必须不同。为了实现更好的可调试性,属性名称还应该提供一些线索,说明每个保险箱属于哪个类。

安全属性模式通过提供一种引导方式来选择安全属性名称来解决此问题。它可以部分使用,仅获取新的安全属性名称,但创建和打开安全属性仍然以通常的方式处理,或者完全使用,获取属性名称,将安全属性添加到实例,然后打开它的整个过程都由一对函数处理。

// SafeProperty pattern
Module(function(){
    // prefix -> previously used id
    var _map = {};
 
    function createName(prefix){
        if(!prefix) prefix = '';
        
        var id = (prefix in _map) ? (++_map[prefix]) : (_map[prefix] = 1);
 
        return "_safe" + prefix + id;
    }
 
    function createSafeProperty(prefix){
        var _safeProp = createName(prefix),
            _safeOpener = SafeFactory.create();
 
        function add(instance, value){
            instance[_safeProp] = _safeOpener.create(value);
        }
 
        function getter(instance){
            return _safeOpener(instance[_safeProp]);
        }
 
        getter.add = add;
        return getter;
    }
    
    this.SafeProperty = {
        createName: createName,
        create: createSafeProperty
    };
});

获取安全属性名称

使用安全属性模式的 `SafeProperty.createName` 函数的区域类版本,以避免安全属性名称冲突

// Region5 class
Module(function(){
    // The safe property prefix is 'Region'
    // Its name will probably be '_safeRegion1'
    var _safeOpener = SafeFactory.create(),
        _safeProp = SafeProperty.createName('Region');
 
    function Region(){
        this[_safeProp] = _safeOpener.create({
            inside: 0
        });
    }
 
    Region.prototype.inside = function(){
        return _safeOpener(this[_safeProp]).inside;
    };
 
    Region.prototype.enter = function(){
        var fields = _safeOpener(this[_safeProp]);
        return ++fields.inside;
    };
 
    Region.prototype.leave = function(){
       var fields = _safeOpener(this[_safeProp]);
       if(fields.inside === 0){
           throw new Error("there's no one inside to leave");
       }
 
       return --fields.inside;
    };
 
    this.Region5 = Region;
});

完全抽象

使用完整安全属性模式的区域类版本

// Region6 class
Module(function(){
    var _safePropGet = new SafeProperty('Region').get;
 
    function Region(){
        _safePropGet.add(this, {
            inside: 0
        });
    }
 
    Region.prototype.inside = function(){
        return _safePropGet(this).inside;
    };
 
    Region.prototype.enter = function(){
        var fields = _safePropGet(this);
        return ++fields.inside;
    };
 
    Region.prototype.leave = function(){
       var fields = _safePropGet(this);
       if(fields.inside === 0){
           throw new Error("there's no one inside to leave");
       }
 
       return --fields.inside;
    };
    
    this.Region6 = Region;
});

安全工厂模式的成本

我进行了测试,以评估所呈现的几种模式的成本。我将每个区域类版本(除了有缺陷的 `Region3`)的成本与 `Region1` 类的成本进行比较——即基于原型的继承模式。此类通过 `this` 关键字访问属性,是最常用的继承模式。所有成本值首先减去*null*区域类(此处未显示)的成本,该类仅执行与其他所有类通用的操作。参考 `Region1` 类表示“实例属性访问”的成本。因此,相对成本为2表示一个模式的执行时间是单个实例属性访问的两倍,或者换句话说,在该时间内,可以访问两个实例属性。

模式 执行的相对成本
Chrome
(v. 8.0.552.224 beta)
Firefox
(v. 4.0 Beta 7)
IE 平台预览
(v. 9.0.8023.6000)
Safari
(v. 5.0.3)
Region1 基于原型的继承 1 1 1 1
Region2 构造函数内部的方法 0.65 1.22 0.28 3.40
Region4 安全工厂 3.52 5.74 2.76 7.32
Region5 具有动态属性的安全工厂 3.53 6.24 11.60 11.48
Region6 安全属性 4.43 13.21 12.41 12.59

结果在区域类和浏览器之间都存在巨大差异。Chrome 浏览器是这四个浏览器中速度最快、最统一的。它在最佳和最差情况的相对成本之间具有最小的差异。有趣的是,一些实现访问实例属性(`Region1`)的速度比访问父作用域变量(`Region2`)的速度快,而另一些则恰恰相反。在 IE 中,使用固定属性(`this._safe`)的安全工厂模式和使用动态属性查找(`this[_safePropName]`)之间存在很大差异,而在 Chrome 中,这两个版本之间几乎没有差异。

我应该指出,这些测试尽可能地衡量了选择一种模式而不是另一种模式的唯一影响。不应得出结论,如果将此模式用于实际应用程序,所呈现的成本就是实际发生的成本。此模式的实际使用只会影响给定代码库中各种模式和指令的一小部分,因此模式的总成本将相应地稀释。

与常用模式成本的比较

我认为我应该与一些广泛使用的模式进行比较,希望能证明我们习惯于毫不犹豫地接受多少速度成本。

当今最常用的“模式”之一是使用接受映射闭包函数的“each”方法进行数组枚举。这通常通过使用 Prototype.js 和 jQuery 等已知 JavaScript 框架的功能来实现。

浏览器最新版本,根据 ECMAScript 5,已经提供了原生且更快的枚举功能实现,即 `Array.forEach` 方法。Prototype.js 框架如果存在此功能,则会使用它,而不是较慢的自定义 `each` 方法实现。我将这两个框架的 `Array` 枚举与使用传统 `for` 循环和使用映射闭包函数的枚举进行比较。

模式 执行的相对成本
Chrome
(v. 8.0.552.224 beta)
Firefox
(v. 4.0 Beta 7)
IE 平台预览
(v. 9.0.8023.6000)
Safari
(v. 5.0.3)
基本 `for` 循环 1 1 1 1
Prototype `each` 循环
(原生 `forEach`)
1.70 1.04 1.49 0.94
jQuery `each` 循环
(非原生循环)
1.17 2.92 2.40 1.07
每个闭包非原生循环 1.24 2.84 2.40 1.12

可惜我无法在 IE6 上测试。它可能会显示出非常有趣的结果。

结论

我重点介绍安全工厂模式的以下特性

  • 是任何 JavaScript 对象隐私的构建块
  • 在一些最新的浏览器实现中,具有可接受的成本,接近其他广泛接受的模式
  • 结合 ECMAScript 5 的常量属性,实现了完全健全的编程模型
  • 是一种在任何具有类似执行模型的动态语言中使用闭包函数实现隐私的模式

历史

  • 版本 1 - 2010-12-15 - 初始版本。
  • 版本 2 - 2010-12-15 - 对区域对象示例进行了一些更正。
  • 版本 3 - 2010-12-16 - 对一些英语措辞进行了更正。
  • 版本 4 - 2014-07-18 - 添加了指向 github 项目的链接。
© . All rights reserved.