ECMAScript6 中 Set 和 Map 的代理方法
1. 概述
1.1 与代理普通对象的区别
Set 和 Map 与普通对象的一个区别是他们具有普通对象没有的属性和方法。代理 Set 和 代理 Map 的思路基本一致,只不过比代理普通对象要麻烦了许多。关键在于你需要完成 Set 和 Map 各具体原型方法的代理实现。因此,在实现对 Set 和 Map 的代理之前,我们需要大略过一下 Set 和 Map 的原型属性与方法。
1.2 Set (集合)的原型属性和方法
原型方法/属性 | 描述 |
Set.prototype.add() | 如果 Set 对象中没有具有相同值的元素,则 add() 方法将插入一个具有指定值的新元素到 Set 对象中。 |
Set.prototype.clear() | 该方法移除 Set 对象中所有元素。 |
Set.prototype.delete() | 该方法从 Set 对象中删除指定的值(如果该值在 Set 中)。 |
Set.prototype.entries() | 该方法返回一个新的迭代器对象,这个对象包含的元素是类似 [value, value] 形式的数组,value 是集合对象中的每个元素,迭代器对象元素的顺序即集合对象中元素插入的顺序。 |
Set.prototype.forEach() | 该方法对 Set 对象中的每个值按插入顺序执行一次提供的函数。 |
Set.prototype.has() | 该方法返回一个布尔值来指示对应的值是否存在于 Set 对象中。 |
Set.prototype.keys() | 该方法是 values() 方法的别名。 |
Set.prototype.values() | 该方法返回一个新的迭代器对象,该对象按插入顺序包含 Set 对象中每个元素的值。 |
Set.prototype[@@iterator]() | @@iterator 属性的初始值和 values 属性的初始值是同一个函数。 |
Set.prototype.size | 该属性将会返回 Set 对象中(唯一的)元素的个数。 |
get Set[@@species] | 该访问器属性返回Set的构造函数。 |
1.3 Map (映射)的原型属性和方法
温馨提示:Map 在编程语言中不叫 地图,而是 映射。
原型方法/属性 | 描述 |
Map.prototype.clear() | 该方法会移除 Map 对象中的所有元素。 |
Map.prototype.delete() | 该法用于移除 Map 对象中指定的元素。 |
Map.prototype.entries() | 该方法返回一个新的迭代器对象,其中包含 Map 对象中按插入顺序排列的每个元素的 [key, value] 对。 |
Map.prototype.forEach() | 该方法按照插入顺序依次对 Map 中每个键/值对执行一次给定的函数。 |
Map.prototype.get() | 该方法从 Map 对象返回指定的元素。 |
Map.prototype.has() | 该方法返回一个布尔值,指示具有指定键的元素是否存在。 |
Map.prototype.keys() | 该返回一个引用的迭代器对象。它包含按照顺序插入 Map 对象中每个元素的 key 值。 |
Map.prototype.set() | 该方法为 Map 对象添加或更新一个指定了键(key)和值(value)的(新)键值对。 |
Map.prototype.values() | 该方法返回一个新的迭代器对象。它包含按顺序插入 Map 对象中每个元素的 value 值。 |
Map.prototype[@@iterator]() | @@iterator 属性的初始值与 entries 属性的初始值是同一个函数对象。 |
Map.prototype.size | 该属性返回 Map 对象的成员数量。 |
get Map[@@species] | 该访问器属性会返回一个 Map 构造函数。 |
2. 代理的实现思路
2.1 从一个错误说起
我们想代理一个 Set 对象实例,于是这样做了:
const s = new Set([1, 2]); const s_proxy = new Proxy(s, {})
接着我们尝试通过代理对象 s_proxy 获取 s 的 size:
s_proxy.size
这时错误产生了:
上面的报错意思是说:在不兼容的接收器 #<Set >上调用了方法 get Set.prototype.size。
这表明,我们直接想要不定义任何东西使用Proxy来代理 Set,首先在 size 属性上就没有成功。为什么的?
这是因为 Set.prototype.size 是一个 get 访问器属性(它的 set 存储器属性未定义)。当调一个Set用该属性时:
因此我们需要在船舰代理对象时增加 getter 拦截方法,并使访问器属性 size 的 getter 函数执行时, this 指向被代理的 Set 对象实例,而不是 代理对象自己:
const s = new Set([1, 2]); const s_proxy = new Proxy(s, { get(target, key, receiver) { // 对于 size 属性 if(key === 'size') { // 返回 target['size'], target 表示被代理的目标对象 return Reflect.get(target, key, target) } // 其它属性 else{ // 仍返回 receiver[key] // receiver表示 Proxy 或者继承 Proxy 的对象 return Reflect.get(target, key, receiver) } } })
【注】:
Reflect.get 方法就如同属性访问器语法(target[propertyKey]) 从对象中读取属性,只不过 Reflect.get 方法 是通过一个函数执行来操作的:
Reflect.get(target, propertyKey[, receiver])
- target: 需要取值的目标对象
- propertyKey: 需要获取的值的键值
- receiver: 如果target对象中指定了getter,receiver则为getter调用时的this值
返回属性的值。
现在,我们的代理对象上访问 size 就不会报错了:
2.2 代理一般原型方法
很快你就意识到,在代理对象 s_proxy
上同样无法使用 add()、clear()、delete() 等等 Set 的原型方法。
很显然,这个问题和 访问器属性 size 不那么一样:
由于 size 是一个Set实例上的访问器属性,我们要使用 s_proxy.size
只需要通过修改 receiver
来改变访问器 getter 函数的 this
指向。
但是 add()
这些函数不像调用 s_proxy.size
会自动执行 getter,不论如何修改 receiver
,访问 s_proxy.add
对应的 add()
方法并没有执行(而访问 s_proxy.set
对应的 get(...)
方法会执行 ),因此这些方法执行时的 this
仍然指向着 代理对象 s_proxy
而不是被它所代理的 s
:
因此我们的目标还是在像一个办法,当执行这些方法是将方法与原始数据对象target绑定。
const s = new Set([1, 2]); const s_proxy = new Proxy(s, { get(target, key, receiver) { if(key === 'size') { return Reflect.get(target, key, target) } else{ return target[key].bind(target); } } })
当调用的是方法时,target[key]
返回就是代理对象上名为 key
的属性,如果是方法则返回的是方法。比如,访问代理对象上的 add
方法时:
s_proxy.add(3)
target[key]
返回的是 ƒ add() { [native code] }
,也就是 add 函数。
因此,target[key].bind(target);
也就是将这个 add() 函数绑定到 target(被代理对象)上。换句话说就是将 target[key]
返回的函数的 this
指向原始对象 target
。
【注】
Function.prototype.bind()
方法该方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
其语法格式为:
function.bind(thisArg[, arg1[, arg2[, ...]]])
- thisArg:调用绑定函数时作为 this 参数传递给目标函数的值。
- arg1, arg2, …:当目标函数被调用时,被预置入绑定函数的参数列表中的参数。
返回:返回一个原函数的拷贝,并拥有指定的 this 值和初始参数。
可知,不仅是 add 方法,其它的方法如 delete、clear 等等,一旦访问的时候会这样绑定到原始对象上执行。
不仅是 Set,Map也可以通过类似的思路完成代理。
为了方便为 Set/Map 类型数据创建代理,我们可将将创建代理的逻辑封装成为一个函数:
function createProxy(obj) { return new Proxy(obj, { get(target, key, receiver) { if(key === 'size') { return Reflect.get(target, key, target) } else { // ... } } }) }
2.3 响应式代理原理与实现
2.3.1 副作用函数与响应式数据
关于响应式数据与副作用函数更详细的介绍请参考博文《响应式数据基本原理》
先介绍一个概念——副作用函数。
所谓副作用函数,指的是当其调用执行后将会直接或者间接地影响到其它函数执行地函数。也就是说,一个函数影响到其它函数地执行,称之为副作用。
副作用函数是我们为了实现响应式数据而提出来的。响应式的目标也就是,副作用函数所依赖的某些变量(称响应式数据)发生改变时,副作用函数自动地随着这些数据的改变而重新执行。这也就意味着,一旦数据改变,由数据驱动的副作用函数对应的效果都将发生相应的变化。
2.3.2 实现响应式的基本思路
举一个例子,由一个数据 data,和一个名为effect的函数:
const data = {p:'我是p标签的文本'} function effect(){ const p = document.getElementsByTagName('p')[0]; p.innerText = data.p; }
如果说 data 是一个响应式数据,那么在这里我们希望一旦 data 的内容发生改变,则函数 effect
(副作用函数)自动执行。
这个问题的关键在于——我们怎么知道数据 data 什么时候会发生改变。毕竟我们还不能定时去读取它的值做比较,这样不仅消耗的资源多,而且 data 仍然称不上响应式,因为定时读取必然有时间间距。
既然不能通过比较获知数据是否发生改变,那我们就只能从引起改变的 源头 进行入手。只要我们能够 拦截 对数据 data 的读取和设置操作,那么我们就可以在拦截中执行副作用函数、从而产生数据变化的“副效果”。
我们可以这样实现:
<p></p> <script> // 副作用函数容器 const container = new Set(); function createProxy(data){ return new Proxy(data, { // 读取属性(方法)时,记录相应副作用函数 get(target, key) { container.add(effect) // 记录副作用 return target[key]; // 返回调用的属性或者方法(属性值可能为方法) }, set(target, key, value) { target[key] = value; // 执行所有存储的副作用函数,产生副作用 container.forEach( (func)=>{ func(); } ) return true; } }) } // 数据 const data = {text:'改变数据则响应式变化:'} // 数据 data 对应的副作用函数 function effect(){ const p = document.getElementsByTagName('p')[0]; p.innerText = data.text; } const dataProxy = createProxy(data); effect(); // 改变数据,则副效果自动执行 // 从而驱动实现数据响应式 setInterval( ()=>{ dataProxy.text = dataProxy.text+'*'; },2000 ) </script>
这个案例中我们使用定时器每隔 2 秒将数据在原数据上增加一个 “*
”。数据改变后,可以看到数据对应的效果也自动改变,效果如下:
2.3.3 为 Set/Map 代理建立响应联系
编写中,尚未完成…
3. forEach方法的处理
4. 迭代器方法的处理
编写中,尚未完成…