由一道面试题引发的思考----如何实现 WeakMap 的 polyfill

1,477 阅读6分钟

前言

这篇文章分享一个困扰了作者很长时间的问题的解决方案。

起因

作为 ES6 新特性的 Set/WeakSet/Map/WeakMap 是面试中常见的高频知识点,而经常被问到的无非就是:对应的方法、与 Obejct/Array 差异、以及它们之间的关系。这些都是很基础的知识点,如果有不清楚的小伙伴可以参考阮一峰老师的 ES6入门教程

在之前的面试中,作者也被问起过这些问题,回答完以上的基础知识后,面试官随口问了一句:如果要实现 WeakMappolyfill ,有什么好的思路嘛?听到这个问题的我当时差不多是这样的反应: 疑惑 在之前的认知里, 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回收。而我们今天的思路与该特性无关。

回到问题本身

仔细回想一下,当时面试官问我的是实现 WeakMappolyfill ,并没有要求 Map。并且相比于 Map, WeakMap 还有一些很重要的特性:

  • 必须是对象
  • 没有 keys()/values()/entries() 等遍历方法,也没有 size 属性
  • 不支持 clear() 方法 也就是说 WeakMap 只有四个方法可用:get()/set()/has()/delete()。而这几个方法有一个共同点,那就是第一个入参都是 key。设想一下:当我们知道要给哪一个对象增加属性,是否能直接加在这个指定的对象上呢?查改删也是同样的道理,这样一来,因为并没有直接引用该对象,也算是间接的实现了弱引用。

顺着这个思路,完成了一个能满足要求的 polyfill

实现

先看一下 typescriptWeakMapinterface

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>;
}

在接口的定义中,我们能很清楚的看出每个方法的参数及返回值,接下来我准备利用 ES6class 语法去完成:

代码结构

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
}

类型补充

最后,我们需要为对象的实例加上指定的类型。

JavaScriptObject.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 的特性,实现了其该有的功能,同样的方式也能实现 WeakSetpolyfill

对于如何能模拟出 Map/Set 结构,目前还没有思路。有想法的大佬欢迎评论区指教!

参考

ES6 入门教程