JavaScript Getters 和 Setters

Posted by George Sun on June 18, 2015

在上一篇文章 JavaScript 属性描述符 中,我提到JavaScript 在 ES5 中引入了对象属性的 GetterSetter,这篇文章就来详细介绍它们。

如何定义 Getters 和 Setters

我们可以用几种方式来定义 Getters 和 Setters,先看第一种:

var o = {
  a: 7,
  get b() { 
    return this.a + 1;
  },
  set c(x) {
    this.a = x / 2
  }
};

console.log(o.a); // 7
console.log(o.b); // 8
o.c = 50;
console.log(o.a); // 25

这里可以看到,我们为对象上的属性 a 定义了 getset 操作,在这两个函数内部通过闭包来引用对象属性 a

还可以通过 Object.defineProperty(...) 来定义:

var d = Date.prototype;
Object.defineProperty(d, "year", {
  get: function() {return this.getFullYear() },
  set: function(y) { this.setFullYear(y) }
});

另外一种方式是这样的:

function Field(val){
    var value = val;
   
    this.getValue = function(){
        return value;
    };
   
    this.setValue = function(val){
        value = val;
    };
}

这里是在函数 Field 上定义,本质上是一样的,因为 JavaScript 的函数也是对象。

Getters 和 Setters 可以做什么

ES5 中引入 GetterSetter 的目的是为了给 JavaScript 语言增加了覆盖对象单个属性(注意,并非一个对象上的所有属性)默认的 [[Get]] 和 [[Put]] 操作的能力,后面我们会再讨论[[Get]] 和 [[Put]]操作。实际上,Getters 是对象上的属性,通过它来调用对象上隐藏的函数来获取值;而 Setters 也是对象的属性,通过调用隐藏的函数来为对象属性赋值。你也可以把它们理解为 JavaScript 对象属性存取描述符 (Accessor Descriptor)。通过前一篇文章我们知道对象的属性描述符 (Property Descriptor)value, writable, configurable, 和 enumerable。如果某个对象的属性定义了存取描述符,valuewritable会被忽略,JavaScript 只会考虑 Getter, Setter, configurable, 和 enumerable。来看一个例子:

var myObject = {
    // define a getter for `a`
    get a() {
        return 2;
    }
};

Object.defineProperty(
    myObject,   // target
    "b",        // property name
    {           // descriptor
        // define a getter for `b`
        get: function(){ return this.a * 2 },

        // make sure `b` shows up as an object property
        enumerable: true
    }
);

myObject.a; // 2

myObject.b; // 4

myObject.a = 3;

myObject.a; // 2

从上面的代码可以看到,我们也可以通过 Object.defineProperty(...) 来定义 GetterSetter。另外,一旦存在存取描述符,并且仅有 Getter 的话,对该属性的赋值会被默认忽略,即便定义了 Setter,因为我们上面的例子中 Getter 只会返回常量 2,所以 a 的值始终是 2。当然了上面只是用来讲解 GetterSetter 的用法,实际使用的代码一般会是这样的:

var myObject = {
    // define a getter for `a`
    get a() {
        return this._a_;
    },

    // define a setter for `a`
    set a(val) {
        this._a_ = val * 2;
    }
};

myObject.a = 2;

myObject.a; // 4

通过 GetterSetter,我们也可以达到动态获取和设置对象属性的值的效果,来看两段代码:

var expr = "foo";

var obj = {
  get [expr]() { return "bar"; }
};

console.log(obj.foo); // "bar"
var expr = "foo";

var obj = {
  baz: "bar",
  set [expr](v) { this.baz = v; }
};

console.log(obj.baz); // "bar"
obj.foo = "baz";      // run the setter
console.log(obj.baz); // "baz"

需要获取和设置的属性可以通过变量 expr 来动态设置,Cool!

JavaScript 的 [[Get]] 和 [[Put]] 操作

上一节,我们已经看到了 GetterSetter 会对 JavaScript 对象属性的存取产生影响,这一节,我们就来看看到底会产生什么样的影响。

先来 [[Get]] 操作:

var myObject = {
    a: 2
};

myObject.a; // 2

看起来很简单,是不是?myObject 上定义了一个叫做 a 的属性,它的值是 2,那么获取值得时候,就自然而然返回了 2。事实上,它背后的机制却不是这么简单。

在这一行代码 myObject.a; 执行的时候,JavaScript 会在对象 myObject 上执行一次 [[Get]] 操作。JavaScript 内置的 [[Get]] 操作首先根据属性名称检查对象本身是否有该属性,如果这时候找到了,那么就返回它的值;如果没找到,那么会通过该对象的 Prototype 链继续向上查找,直到顶层的 Object.prototype。关于 Prototype,以后我会用一到两篇文章讲述。

再来看更复杂的 [[Put]] 操作:

var myObject = {
    a: 2
};

myObject.a = 3;

myObject.a = 3; 这行代码运行的时候,你可能会以为 JavaScript 仅仅会给 myObject 上已有的 a 属性赋值,如果没有的话就创建一个叫做 a 的属性,事实上,背后的机制要复杂得多。

首先,JavaScript 会触发一个 [[Put]] 操作,它的行为会根据要赋值的属性是否已经直接存在于该对象上而有所不同。如果该属性已经存在了,[[Put]] 操作会执行如下步骤:

  1. 该属性是否已经定义了 Setter,如果已经定义了,那么调用它;
  2. 该属性的属性描述符 (Property Descriptor)是否定义了 writable: false,如果是,那么赋值操作被忽略,如果是 strict mode,那么会抛出 TypeError 异常;
  3. 如果没有上述情况,那么给已存在的属性赋值。

如果被赋值的属性不是直接存在于对象上:

var anotherObject = {
    a: 2
};

var myObject = Object.create( anotherObject );
myObject.foo = "bar";
  1. [[Put]] 操作首先会搜索 Prototype 链,如果没有找到 foo,那么就在被赋值的对象上直接创建 foo,并赋值为 bar
  2. 如果在 Prototype 链上找到了 foo,代码的行为就会变得诡异起来,我们回头再看。在此之前,先来了解一个概念,叫做变量隐藏 (Variable Shadowing)。当一个变量既直接存在于对象本身,又存在于对象的 Prototype 链,那我们就可以说对象本身的属性 foo 隐藏了存在于 Prototype 链的变量 foo。理解了这个概念,我们再来看当变量 fooPrototype 链上存在的的时候,给 foo 赋值可能带来的三种结果:

    1. 如果 fooPrototype 链上存在,并且它没有被定义为只读的 (writable: false),那么一个名叫 foo 的属性会被直接添加到 myObject,从而造成 变量隐藏 (Shadowing)
    2. 如果 fooPrototype 链上存在,并且被标记为只读的 (writable: false),那么对 foo 的赋值操作会被忽略;如果是 strict mode,那么会抛出异常;
    3. 如果 fooPrototype 链上存在,并且它具有 Setter,那么 Setter 始终会被调用,也就是说没有属性会被直接添加到 myObject 上,也不会发生变量隐藏。也许你还记得,当对象属性 Setter 存在的时候,给该属性赋值,不会检查 writable

我们可以看到,只有情形1会造成变量隐藏。变量隐藏是我们在写 JavaScript 代码的时候,需要尽量避免的情形,因为它可能会引入难以维护的,丑陋的代码。另外,它还有一些让人吃惊的行为,来看下面的代码:

var anotherObject = {
    a: 2
};

var myObject = Object.create( anotherObject );

anotherObject.a; // 2
myObject.a; // 2

anotherObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "a" ); // false

myObject.a++; // oops, implicit shadowing!

anotherObject.a; // 2
myObject.a; // 3

myObject.hasOwnProperty( "a" ); // true

你可以看到,myObject.a++ 本来应该是对其 Prototype 链上的属性 a 执行操作,可这里它的行为等同于 myObject.a = myObject.a + 1,也就是说这句代码先执行了 [[Get]] 操作,从 Prototype 链上得到 a = 2,然后对其执行 ++ 操作,并对 myObject 执行 [[Put]] 操作,把最终的结果 3 赋值到 myObject.a。诡异吧!

资源

You Don’t Know JS: this & Object Prototypes

Working with objects

JavaScript Getters and Setters

Mozilla MDN - getter

Mozilla MDN - setter