前言
这篇文章分享一个困扰了作者很长时间的问题的解决方案。
起因
作为 ES6 新特性的 Set/WeakSet/Map/WeakMap 是面试中常见的高频知识点,而经常被问到的无非就是:对应的方法、与 Obejct/Array 差异、以及它们之间的关系。这些都是很基础的知识点,如果有不清楚的小伙伴可以参考阮一峰老师的 ES6入门教程。
在之前的面试中,作者也被问起过这些问题,回答完以上的基础知识后,面试官随口问了一句:如果要实现 WeakMap 的 polyfill ,有什么好的思路嘛?听到这个问题的我当时差不多是这样的反应:
在之前的认知里,
Map 等数据结构应该是和 Proxy 类似,都是底层的实现,从来没思考过这些语法也能有对应的 polyfill。既然问了,那肯定有实现的方式。在接下来深思的30s左右的时间内,一些诸如:弱引用、Hash 表、O(1) 时间复杂度等词在脑海中飞速闪过......最终还是没能想到一个有效的解决方案。
最初的思路
要实现 WeakMap 首先得实现 Map,在后者实现的基础上,判断 key 只能是对象,并且实现弱引用。而 Map 得支持对象作为键,并且得实现查改的 O(1) 时间复杂度。我们知道 JavaScript 中对象的键只能是 String 或者 Symbol ,其他类型都会隐式调用对应的 .toString() 方法转成 String 类型。而用数组存储,又无法实现 O(1) 复杂度的查询。就算能有其他方式实现 Map,弱引用是完全没法实现的(ES2021之前)。所以我曾一度认为这就是一个目前无法解决的问题。
关于弱引用的实现,ES2021 推出了
WeakRef,能实现保留对另一个对象的弱引用,而不会阻止该弱引用对象被GC回收。而我们今天的思路与该特性无关。
回到问题本身
仔细回想一下,当时面试官问我的是实现 WeakMap 的 polyfill ,并没有要求 Map。并且相比于 Map, WeakMap 还有一些很重要的特性:
- 键 必须是对象
- 没有
keys()/values()/entries()等遍历方法,也没有size属性 - 不支持
clear()方法 也就是说WeakMap只有四个方法可用:get()/set()/has()/delete()。而这几个方法有一个共同点,那就是第一个入参都是key。设想一下:当我们知道要给哪一个对象增加属性,是否能直接加在这个指定的对象上呢?查改删也是同样的道理,这样一来,因为并没有直接引用该对象,也算是间接的实现了弱引用。
顺着这个思路,完成了一个能满足要求的 polyfill
实现
先看一下 typescript 中 WeakMap 的 interface
interface WeakMap<K extends object, V> {
delete(key: K): boolean
get(key: K): V | undefined
has(key: K): boolean
set(key: K, value: V): this
}
interface WeakMapConstructor {
new <K extends object = object, V = any>(entries?: readonly [K, V][] | null): WeakMap<K, V>;
readonly prototype: WeakMap<object, any>;
}
在接口的定义中,我们能很清楚的看出每个方法的参数及返回值,接下来我准备利用 ES6 的 class 语法去完成:
代码结构
class WeakMap<K extends object = object, V = any> implements WeakMap<K, V> {
private uid: symbol
constructor (entries?: readonly [K, V][] | null | undefined) {}
// 对象类型保护函数, 是否为合法的 key 值
private isLegal(o: unknown): o is object {
return Object(o) === o
}
delete (key: K): boolean {}
get(key: K): undefined | V {}
has(key: K): boolean {}
set(key: K, value: V): this {}
}
以上为类的总体结构,包含了公用的方法、内部属性及对应的 curd 方法,接下来我们会一一实现对应的方法体......
constructor
...
constructor (entries?: readonly [K, V][] | null | undefined) {
this.uid = Symbol('WeakMap')
if (entries !== undefined && entries !== null) {
if (typeof entries[Symbol.iterator] === 'function') {
try {
for (const [key, value] of entries) {
this.set(key, value)
}
} catch {
throw TypeError(`Iterator value a is not an entry object`)
}
} else {
throw TypeError(
`${entries} is not iterable (cannot read property Symbol(Symbol.iterator))`
)
}
}
}
...
构造函数中我们一共做了两件事:
生成实例的唯一标识
这里采用 Symbol 作为标识,这样能将绑定到指定 key 值对象上的属性,作为我们 polyfill 的 '私有属性',主要是利用了 Symbol 的特性:Symbol('WeakMap') !== Symbol('WeakMap') ,使得定义的属性无法被其他方法获取到。
polyfill 理论上都应该使用 ES6 之前语法实现,不过为了方便讲解,我还是决定用
Symbol。当然,拼接随机字符串作为uid也是一样的效果。
绑定的
Symbol属性,虽然无法直接访问或者遍历,但是可以通过Object.getOwnPropertySymbols(target)获取到......
处理入参
从给出的接口定义可以看出,构造实例对象的时候,可传入一个可迭代的对象作为参数。对象的第一个元素是 key, 第二个元素是 value,直接遍历该对象,并调用 set 方法进行绑定即可。
对入参的格式做了判断,必须是可迭代对象,也就是部署了 Symbol.iterator 属性的对象,并根据实际结果,抛出不同的报错。
经测试得出,不一定准确
set
从接口定义可以看出,set 方法的的第一个入参为弱引用且必须是对象的键,第二个入参是绑定的值,并且返回了当且实例的 this ,以支持链式调用,实现代码如下:
...
set (key: K, value: V): this {
// key 值非对象时,直接抛出类型错误
if (!this.isLegal(key)) {
throw TypeError('Invalid value used as weak map key')
}
// 当已设置的情况下,修改原值
if (this.uid in key) {
const entry = key[this.uid]
entry[1] = value
return this
}
Object.defineProperty(key, this.uid, {
value: [key, value],
configurable: true,
writable: true,
enumerable: false
})
return this
}
...
通过
Object.defineProperty设置值时需要注意,一定要将属性的enumerable设置为false,避免原对象在常规的遍历方法中取到我们设置的属性。
has
has 方法传入 key 并且返回 boolean 值,在 key 类型错误或未设置时返回 false
...
has (key: K) {
if (!this.isLegal(key)) return false
if (key.hasOwnProperty(this.uid)) return true
return false
}
...
get
get 方法传入要获取的 key 值,并且当 key 是不合法的键或者未设置时返回 undefined ,否则返回对应的值
...
get(key: K): undefined | V {
if (!this.isLegal(key)) return undefined
if (!key.hasOwnProperty(this.uid)) return undefined
const entry = key[this.uid]
return entry[1]
}
...
delete
delete 方法传入要删除的 key 值,当 key 是不合法的键或者未设置过时都返回 false,否则删除并返回 true
delete(key: K) {
if (!this.isLegal(key)) return false
if (!key.hasOwnProperty(this.uid)) return false
delete key[this.uid]
return true
}
类型补充
最后,我们需要为对象的实例加上指定的类型。
JavaScript 中 Object.prototype.toString 是最普遍的用来判断类型的方式。WeakMap 的实例的类型为 [object WeakMap],所以也需要给我们的类加上
Object.defineProperty(WeakMap.prototype, Symbol.toStringTag, {
value: 'WeakMap'
})
关于为什么定义
Symbol.toStringTag就可以为对象绑定Object.prototype.toString可以获取的类型,不清楚的小伙伴可以看官方文档 这边有一点需要注意的是,我们需要将Symbol.toStringTag定义在原型上,而不能直接在类中以属性的方式定义,因为这是原型的属性而非实例属性。
测试
const weakMap = new WeakMap<object, any>() // new 一个实例
const o = {
name: 'juejin'
}
weakMap.set(o, 'done')
weakMap.get(o) // done
weakMap.has(o) // true
WeakMap(Polyfill) 实例结构
作为 Key 值的对象
总结
至此,我们实现了一个简单的 WeakMap,虽并未能真正模拟出它的数据结构,不过也算是利用 WeakMap 的特性,实现了其该有的功能,同样的方式也能实现 WeakSet 的 polyfill。
对于如何能模拟出 Map/Set 结构,目前还没有思路。有想法的大佬欢迎评论区指教!