getter和setter控制对象的访问及代理对象Proxy

本文深入探讨JavaScript中使用getter和setter控制属性访问的方法,以及通过代理控制对象访问的高级技巧,包括日志记录、性能检测和自动属性填充。

8.1 使用getter与setter控制属性访问

  • 在JavaScript中,对象是相对简单的属性集合。保持程序状态的主要方法是修改对象的这些属性。

    function Ninja (level) {
      this.skillLevel = level;
    }
    const ninja = new Ninja(100);
    
    
  • 这里定义构造函数Ninja,使用该构造函数创建实例ninja,它仅具有一个属性skillLevel。现在,想更改属性skillLevel,可通过这行代码实现:

    ninja.skillLevel=20

  • 这样实现很方便,但是在如下情况中会发生什么呢?

    • 需要避免意外的错误发生,例如赋值错误。例如,需要避免赋予了错误类型的值:ninja.skillLevel="high"
    • 需要记录skillLevel属性的变化。
    • 需要在网页的UI中显示skillLevel属性的值。
  • 通过getter和setter方法,可以很优雅的实现这一切。

  • 可以使用getter和setter,在JavaScript的闭包中实现模拟私有对象属性。下面例子中只通过getter与setter方法访问ninja的私有属性skillLevel。

    function Ninja() {
    	let skillLevel;
    
    	this.getSkillLevel = () => skillLevel;
    
    	this.setSkillLevel = value => {
    		skillLevel = value;
    	};
    }
    const ninja = new Ninja();
    ninja.setSkillLevel(100);
    assert(ninja.getSkillLevel() === 100, "Our ninja is at level 100!");
    
  • 通过Ninja构造器创建ninja实例,该实例具有私有属性skillLevel,该属性只能通过getSkillLevel和setSkillLevel方法访问:只能通过getSkillLevel方法获取属性值,也只能通过setSkillLevel方法设置新值。

  • 如果要记录所有对skillLevel属性的访问,可以拓展getSkillLeve方法,同样,要记录对skillLevel属性赋值,可以拓展setSkillLevel方法:

    function Ninja() {
      let skillLevel;
      this.getSkillLevel = () => {
        report('Getting skill level value');
        return skillLevel;
      };
      this.setSkillLevel = value => {
        report('Modifying skillLevel property from:', skillLevel, 'to:', value);
        skillLevel = value;
      };
    }
    
  • 这样可以轻松应对所有交互属性,例如插入日志、数据验证或其他副作用,如界面修改等。

  • skillLevel属性是数值,而不是函数。为了利用所有访问控制的优点,所有与skillLevel属性交互的地方都必须显式的调用相关方法。好在,JavaScript自身支持真正的getter和setter:用于访问普通数据属性(如ninja.skillLevel ),同时可以计算属性值、校验属性值,或其他想做的事。

8.1.1 定义getter与setter

  • 在JavaScript中,可以通过两种方式定义getter和setter:

    • 通过对象字面量定义,或在ES6的class中定义。
    • 通过使用内置的Object.defineProperty方法。
  • 自ES5以来,明确支持getter和setter方法已经有一段时间了。

    const ninjaCollection = {
        ninjas: ["Yoshi", "Kuma", "Hattori"],
        get firstNinja() {
          report("Getting firstNinja");
          return this.ninjas[0];
        },
        set firstNinja(value) {
          report("Setting firstNinja");
          this.ninjas[0] = value;
        }
    };
    
    assert(ninjaCollection.firstNinja === "Yoshi",
          "Yoshi is the first ninja");
    
    ninjaCollection.firstNinja = "Hachi";
    
    assert(ninjaCollection.firstNinja === "Hachi"
          && ninjaCollection.ninjas[0] === "Hachi",
          "Now Hachi is the first ninja");
    
  • 通过在属性名前添加关键字get定义getter方法,通过在属性名前添加关键字set定义setter方法。getter和setter都在记录日志,同时,getter返回索引为0的ninja,setter在同一个位置赋值。下面,验证访问getter属性返回的第一个ninja,Yoshi,要注意getter属性与标准对象属性的访问方法一致。

  • 访问getter属性时,隐式调用关联的getter方法,记录了获取firstNinja的日志,并返回索引值为0的ninja。

  • 继续使用setter方法设置firstNinja属性,正如直接为普通对象赋值过程一样:ninjaCollection.firstNinja = "Hachi";

  • 由于firstNinja属性具有setter方法,无论何时对firstNinja属性赋值,都会隐式调用setter方法,日志记录设置firstNinja的值,修改索引为0的ninja的值,索引为0的ninja的值可以通过ninjaCollection访问,也可以通过getter方法访问。

  • 当通过getter访问属性时(ninjaCollection.firstNinja),将立即调用getter方法同时立即记录日志,验证输出的是Yoshi,这是记录的第一条日志,继续对firstNinja属性赋值,可以看出隐式调用setter方法,日志记录,设置firstNinja的值。

  • 注意:

    • 访问具有getter方法的属性时隐式调用对应的getter方法。
    • 为具有getter方法的属性赋值时隐式调用对应的setter方法。
  • 可以通过原生的getter和setter设置标准属性,但是这些方法是在访问属性时立即执行的。

在ES6的class中使用getter和setter
class NinjaCollection {
    constructor() {
      this.ninjas = ["Yoshi", "Kuma", "Hattori"];
    }

    get firstNinja() {
      report("Getting firstNinja");
      return this.ninjas[0];
    }

    set firstNinja(value) {
      report("Setting firstNinja");
      this.ninjas[0] = value;
    }
}

const ninjaCollection = new NinjaCollection();

assert(ninjaCollection.firstNinja === "Yoshi",
      "Yoshi is the first ninja");

ninjaCollection.firstNinja = "Hachi";

assert(ninjaCollection.firstNinja === "Hachi"
      && ninjaCollection.ninjas[0] === "Hachi",
      "Now Hachi is the first ninja");
  • 注意:

    • 针对指定的属性不一定需要同时定义getter和setter。通常仅仅提供getter,如果在这种情况下试图写入属性值,具体的行为取决于代码是在严格模式还是非严格模式。如果在非严格模式下,对仅有getter的属性赋值不起作用,JavaScript引擎默默地忽略我们的请求。如果在严格模式下,JavaScript引擎会抛出异常,表明试图给一个仅有getter没有setter的属性赋值。
  • 由于对象字面量与类、getter和setter方法不是在同一个作用域中定义的,因此那些希望作为私有对象属性的变量是无法实现的,不过可以通过Object.defineProperty方法实现。

  • Object.defineProperty方法可以用于定义新的属性,传入属性描述对象即可。属性描述对象可以包含get和set来定义getter和setter方法。

  • 使用这种新特性实现内置的getter和setter,控制私有对象属性的访问。

function Ninja() {
  let _skillLevel = 0;

  Object.defineProperty(this, 'skillLevel', {
    get: () => {
      report("The get method is called");
      return _skillLevel;
    },
    set: value => {
      report("The set method is called");
      _skillLevel = value;
    }
  });
}

const ninja = new Ninja();

assert(typeof ninja._skillLevel === "undefined",
    "We cannot access a 'private' property");
assert(ninja.skillLevel === 0, "The getter works fine!");

ninja.skillLevel = 10;
assert(ninja.skillLevel === 10, "The value was updated");
  • 定义一个Ninja构造函数,该构造函数含有_skillLevel属性作为私有变量

  • 通过内置的Object.defineProperty方法,this引用新创建的对象

    Object.defineProperty(obj, prop, descriptor)
    obj:要在其上定义属性的对象。
    prop:要定义或修改的属性的名称
    descriptor:将被定义或修改的属性描述符。
    
  • 由于希望通过skillLevel属性控制访问私有变量,因此定义了get和set方法,与对象字面量和类中的getter和setter不同,通过Object.defineProperty创建的get和set方法,与私有skillLevel属性处于相同的作用域中。get和set方法分别创建了含有私有变量的闭包,只能通过get和set方法访问私有变量。

  • 创建的新的ninja实例,验证无法直接访问私有变量,所有的交互必须通过getter和setter,与标准对象属性无差异。

    // 访问skillLevel属性将隐式调用get方法
    ninja.skillLevel === 0
    // 对skillLevel属性赋值将隐式调用set方法
    ninja.skillLevel = 10
    
  • Object.defineProperty方法比对象字面量或类更为复杂,但是,当需要实现私有对象属性时,Object.defineProperty方法就能派上用场了。

  • 不管定义方式,getter和setter允许定义对象属性与标准对象属性一样,当访问属性或对属性赋值时,将会立即调用getter和setter方法。这个功能可以执行日志记录,验证属性值,甚至在发生变化时可以通知其他部分代码。

8.1.2 使用getter与setter校验属性值

  • 当对属性赋值时,会立即调用setter方法。可以利用这一点,在代码试图更新属性的值时实现一些行为,例如实现值的校验。
function Ninja() {
  let _skillLevel = 0;

  Object.defineProperty(this, 'skillLevel', {
    get: () => _skillLevel,
    set: (value) => {
      // 校验传入的值是否是整型,如果不是则抛出异常
      if (!Number.isInteger(value)) {
        throw new TypeError("Skill level should be a number");
      }
      _skillLevel = value;
    }
  });
}

const ninja = new Ninja();
// 将整型值赋值给属性skillLevel
ninja.skillLevel = 10;
assert(ninja.skillLevel === 10, "The value was updated");

try {
  // 将非整型值赋值给属性skillLevel
  ninja.skillLevel = "Great";
  fail("Should not be here");
} catch (e) {
  pass("Setting a non-integer value throws an exception");
}
  • 现在无论何时对skillLevel属性赋值,都会校验该值是否是整型,如果不是,则抛出异常,并且不会修改属性_skillLevel的值。如果是整型,则对属性_skillLevel赋值。try-catch可以规避指定属性发生类型错误异常,为此需要增加性能开销,但是,在JavaScript语言中,为了安全需要付出性能开销。

8.1.3 使用getter与setter定义如何计算属性值

  • 除了能控制指定对象属性的访问之外,getter和setter还可以用于定义属性属性值的计算方法,即每次访问该属性时都会进行计算属性值。计算属性不会存储具体的值,它们提供get和set方法,用于直接提取、设置属性。下面的对象shogun具有name和clan两个属性,通过这两个属性计算fullTitle的属性值。
const shogun = {
  name: "Yoshiaki",
  clan: "Ashikaga",
  get fullTitle() {
    return this.name + " " + this.clan;
  },
  set fullTitle(value) {
    const segments = value.split(" ");
    this.name = segments[0];
    this.clan = segments[1];
  }
};

assert(shogun.name === "Yoshiaki", "Our shogun Yoshiaki");
assert(shogun.clan === "Ashikaga", "Of clan Ashikaga");
assert(shogun.fullTitle === "Yoshiaki Ashikaga",
    "The full name is now Yoshiaki Ashikaga");

shogun.fullTitle = "Ieyasu Tokugawa";
assert(shogun.name === "Ieyasu", "Our shogun Ieyasu");
assert(shogun.clan === "Tokugawa", "Of clan Tokugawa");
assert(shogun.fullTitle === "Ieyasu Tokugawa",
    "The full name is now Ieyasu Tokugawa");
  • 当访问fullTitle属性时调用get方法计算该属性值,并连接name与clan属性的值。同时,set方法使用内置的split方法,对传入的参数使用空格进行分割,分割后的数组的第一个值赋值给name属性,数组的第二个值赋值给clan属性。
  • 这需要同时考虑两个方面:读取fullTitle属性时计算属性值,设置fullTitle属性值时修改属性值的构成属性的属性值。
  • 不需要使用计算属性,名为fullTitle的方法可能同样有用,但计算属性可以使得代码的概念更为清晰,如果一个属性值取决于对象内部的状态,这样会更为清晰地表明该值是一个数据字段、一个属性值,而不是一个函数。
  • getter和setter可以用于处理日志记录、数据验证、属性值变化检测等,不好的是,getter和setter方法有时并不够。有时需要控制对象的全部交互类型,这种情况下可使用一种全新的对象类型:代理。

8.2 使用代理控制访问

代理(proxy)是通过代理控制对另一个对象的访问。通过代理可以定义当对象发生交互时可执行的自定义行为——如读取或设置属性值,或调用方法。可以将代理理解为通用化的setter和getter,区别是每个setter与getter仅能控制单个对象属性,而代理可用于对象交互的通用处理,包括调用对象的方法。

使用代理可以在代码中添加分析和性能度量,自动填充对象属性以避免null异常,包装宿主对象,例如DOM用于减少跨浏览器的不兼容性。

// 通过Proxy构造器创建代理
const emperor = {name: "Komei"};
const representative = new Proxy(emperor, {
  get: (target, key) => {
    report("Reading " + key + " through a proxy");
    return key in target ? target[key]
      : "Don’t bother the emperor!"
  },
  set: (target, key, value) => {
    report("Writing " + key + " through a proxy");
    target[key] = value;
  }
});

assert(emperor.name === "Komei", "The emperor’s name is Komei");
assert(representative.name === "Komei",
  "We can get the name property through a proxy");

assert(emperor.nickname === undefined,
  "The emperor doesn’t have a nickname ");
assert(representative.nickname === "Don’t bother the emperor!",
  "The proxy jumps in when we make inproper requests");

representative.nickname = "Tenno";
assert(emperor.nickname === "Tenno",
  "The emperor now has a nickname");
assert(representative.nickname === "Tenno",
  "The nickname is also accessible through the proxy");
  • 创建基础对象emperor,该对象含有name属性,然后,通过使用内置的Proxy构造函数,将对象emperor包装为代理对象representative。同时向代理构造函数传入第2个参数,第2个参数是一个对象,该对象内定义了在对象执行特定行为时触发的函数。
  • 指定两个方法,试图通过代理对象访问对象属性时调用的get方法,以及试图对对象属性赋值时调用的set方法。get执行以下功能:如果目标对象具有该属性,则返回该属性值;如果目标对象不具有该属性,则返回消息以示警告。
  • 若通过目标对象emperor直接访问name属性,则返回Komei。但是,若通过代理对象访问,则隐式调用get方法。由于在目标对象上可以找到name属性,因此也会返回Komei。
  • 注意:
    激活代理方法与getter和setter是一致的。一旦执行交互,就会隐式调用对应的get方法,此时JavaScript引擎的执行过程与显示调用的普通函数类似。
  • 另一方面,如果通过目标对象emperor直接访问不存在的属性nickname,毫无疑问将返回undefined。但是,如果通过代理对象访问不存在的属性nickname,将会激活get方法。由于目标不具有nickname属性,get方法将会返回消息Don’t bother the emperor!
  • 接着通过代理对象分配一个新属性:representative.nickname = “Tenno”;。由于是通过代理对象进行分配的,因此调用set,记录日志消息,并对目标对象emperor设置新属性。也可以通过代理对象和目标对象访问新创建的属性。
  • 使用代理对象的要点:通过Proxy构造器创建代理对象,代理对象访问目标对象时执行指定操作。
  • 还有其他内置方法用于定义各种对象的行为:
    • 调用函数时激活apply,使用new操作符时激活construct
    • 读取/写入属性时激活get与set
    • 执行for-in语句时激活enumerate
    • 获取和设置属性值时激活getPrototypeOf与setPrototypeOf

8.2.1 使用代理记录日志

当试图明白代码是如何工作的或查找严重错误的根源时,最有力的工具之一是日志记录,在特定的时刻输出有用的行为信息。

// 不使用代理实现日志记录
function Ninja() {				
    let _skillLevel = 0;				

    Object.defineProperty(this, 'skillLevel', {		
      get: () => {					
        report("skillLevel get method is called");
        return _skillLevel;
      },							
      set: (value) => {					       
        report("skillLevel set method is called");
        _skillLevel = value;
      }									
    });
}
  
const ninja = new Ninja();
ninja.skillLevel;
ninja.skillLevel = 4;
  • 定义构造器Ninja,并为skillLevel属性添加getter和setter,分别记录读取与写入skillLevel属性的日志。
  • 这不是最理想的解决方案,这混合了对象属性读写的代码与日志代码。如果将来需要ninja对象更多的属性,将不得不小心翼翼的为每个属性都添加日志记录语句。

代理的直接用途之一是在读写属性时使用一种更好的、更清洁的方式启用日志记录。

// 使用代理实现日志记录
function makeLoggable(target){
  return new Proxy(target, {
    get: (target, property) => {
      report("Reading " + property);
      return target[property];
    },

    set: (target, property, value) => {
      report("Writing value " + value + " to " + property);
	  target[property] = value;
    }
  });
}

let ninja = { name: "Yoshi"};
ninja = makeLoggable(ninja);

assert(ninja.name === "Yoshi", "Our ninja Yoshi");
ninja.weapon = "sword";
  • 定义makeLoggable函数,使用target对象作为形参,返回一个新的代理对象,该代理对象具有get和set方法。get和set方法会在读取对象属性时记录日志。
  • 然后创建具有name属性的ninja对象,将该ninja对象传入makeLoggable函数,作为新创建的代理对象的目标函数。将代理对象重新复制给ninja标识符。
  • 每当试图读取属性时,程序将调用get方法,记录对应的读取日志。同理,当写入属性ninja.weapon = “sword”;时也会记录日志。
  • 这种日志记录方式比使用标准的getter和setter方法更容易、更透明。

8.2.2 使用代理检测性能

代理还可以在不需要修改函数代码的情况下,评估函数调用的性能。例如想要评估计算一个数值是否是素数的函数的性能。

// 使用代理评估性能
function isPrime(number){
  if(number < 2) { return false; }
				
  for(let i = 2; i < number; i++) {
	if(number % i === 0) { return false; }
  }
				
  return true;
}
			
isPrime = new Proxy(isPrime, {
  apply: (target, thisArg, args) => {
    console.time("isPrime");
					
    const result = target.apply(thisArg, args);
										
    console.timeEnd("isPrime");
					
    return result;					
  }
});							
				
isPrime(1299827);
  • 定义isPrime方法。现在需要评估isPrime函数的性能,并且不能修改该函数的代码。可以使用代理包装该函数,添加一个一旦调用该函数就会被触发的方法。
  • 使用isPrime函数作为代理的目标对象。同时,添加apply方法,当调用isPrime函数时就会调用apply方法。
  • 将新创建的代理对象赋值给isPrime标识符。这样,无需修改isPrime函数内部代码,就可以调用apply方法实现isPrime函数的性能评估,程序代码的其余部分可以完全无视这些变化。
  • 每当调用isPrime函数时,都会进入代理的apply方法,开启内置的console.time方法秒表计时,调用原始的isPrime函数,记录运行时间,最后返回isPrime调用的结果。

8.2.3 使用代理自动填充属性

代理还可以用于自动填充属性。

// 使用代理自动填充属性
function Folder() {
return new Proxy({}, {
  get: (target, property) => {
     report("Reading " + property);
     
     if(!(property in target)) {
        target[property] = new Folder();
     }

     return target[property];
  }
});
}

const rootFolder = new Folder();

try {
  rootFolder.ninjasDir.firstNinjaDir.ninjaFile = "yoshi.txt";
  pass("An exception wasn’t raised");
}
catch(e){
  fail("An exception has occurred");
}
  • 访问ninjasDir上未定义的属性firstNinjaDir。预期是抛出一个异常,但是执行代码会发现一切运行正常。
  • 因为使用了代理,所以每次访问属性时,代理方法都被激活。如果访问的属性在文件夹对象存在,则直接返回对应的值;如果不存在,将会创建新的文件夹并赋值给该属性。这是ninjasDir与firstNinjaDir属性被创建的原因。
  • 这样就实现了摆脱null异常的工具。

8.2.4 使用代理实现负数组索引

// 使用代理实现数组负索引
function createNegativeArrayProxy(array){
  if (!Array.isArray(array)) {
    throw new TypeError('Expected an array');
  }
  
  return new Proxy(array, {
    get: (target, index) => {
      index = +index;		    	
      return target[index < 0 ? target.length + index : index];
    },
    set: (target, index, val) => {
     index = +index;
     return target[index < 0 ? target.length + index : index] = val;
    }
  });
}

const ninjas = ["Yoshi", "Kuma", "Hattori"];
const proxiedNinjas = createNegativeArrayProxy(ninjas);

assert(ninjas[0] === "Yoshi" && ninjas[1] === "Kuma"
    && ninjas[2] === "Hattori",
       "Array items accessed through positive indexes");

assert(proxiedNinjas[0] === "Yoshi" && proxiedNinjas[1] === "Kuma"
    && proxiedNinjas [2] === "Hattori",
       "Array items accessed through positive indexes on a proxy");

assert(typeof ninjas[-1] === "undefined"
    && typeof ninjas[-2] === "undefined" 
    && typeof ninjas[-3] === "undefined",
       "Items cannot be accessed through negative indexes on an array");

assert(proxiedNinjas[-1] === "Hattori"
    && proxiedNinjas[-2] === "Kuma"
    && proxiedNinjas[-3] === "Yoshi");

proxiedNinjas[-1] = "Hachi";
assert(proxiedNinjas[-1] === "Hachi" && ninjas[2] === "Hachi",
       "Items can be changed through negative indexes");
  • 使用一元+操作符将属性名变成数值(index = +index)。如果索引值小于0,则逆向访问数组;如果索引值大于或等于0,则使用标准的数组元素。
  • 正常数组上只能使用正索引。如果使用代理数组,则同时可以使用正索引和负索引。

8.2.5 代理的性能消耗

代理是通过代理对象控制对另一个对象的访问。

// 检查代理的性能限制
function createNegativeArrayProxy(array){
  if (!Array.isArray(array)) {
    throw new TypeError('Expected an array');
  }
  
  return new Proxy(array, {
    get: (target, index) => {
      index = +index;		    	
      return target[index < 0 ? target.length + index : index];
    },
    set: (target, index, val) => {
     index = +index;
     return target[index < 0 ? target.length + index : index] = val;
    }
  });
}

function measure(items){
	const startTime = new Date().getTime();

	for(let i = 0; i < 500000; i++){
		items[0] === "Yoshi";
		items[1] === "Kuma";
		items[2] === "Hattori";
	}

	return new Date().getTime() - startTime;
}

const ninjas = ["Yoshi", "Kuma", "Hattori"];
const proxiedNinjas = createNegativeArrayProxy(ninjas);

console.log("Proxies are around",
Math.round(measure(proxiedNinjas)/measure(ninjas)), "times slower");
  • 尽管使用代理可以创造性地控制对象的访问,但是大量的控制操作将带来性能问题。可以在对性能不敏感的程序里使用代理,但是若多次执行代码时仍要小心谨慎。建议彻底地测试代码的性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值