Symbol Polyfill 填坑之旅

912 阅读5分钟
原文链接: zhuanlan.zhihu.com

代码:gist.github.com/liril-net/4…

Symbol 是 ECMAScript 2015 中新提出的一个『原始数据类型』,为了增加对其的理解,笔者尝试去实现其 Polyfill ,并在这个过程中,发现了许多有趣的知识点,特此记录。

Symbol 是什么?

要想实现 Symbol 我们要知道 Symbol 是什么,简单来说 『Symbol 是颜色不一样的烟火』。它有以下几个特点:

  1. Symbol() 是函数,有一个可选的字符串参数

    Symbol()
    Symbol('Eleme')
    
  2. 返回的是原始数据类型 Symbol

    const ELEME = Symbol('Eleme')
    typeof ELEME  // symbol
    console.log(ELEME)  // Symbol(Eleme)
    
  3. 具有唯一性

    Symbol('Eleme') === Symbol('Eleme')  // false
    
  4. 不允许作为构造器使用,即不允许使用 new

    new Symbol('Eleme')  // TypeError: Symbol is not a constructor
    
  5. 不允许隐式转换成字符串

    '' + Symbol('Eleme')  // TypeError: Cannot convert a Symbol value to a string
    String(Symbol('Eleme'))  // Symbol(Eleme)
    
  6. 不允许转换成数字

    1 + Symbol('Eleme')  // TypeError: Cannot convert a Symbol value to a number
    Number(Symbol('Eleme'))  // TypeError: Cannot convert a Symbol value to a number
    
  7. 有两个静态方法 for 和 keyFor

    typeof Symbol.for  // function
    typeof Symbol.keyFor // function
    
  8. 有几个内置 Symbol 属性

接下来我们会根据规范,尝试去实现一个 Polyfill,一共分为 4 部分的实现:

  1. 构造器

  2. 构造器的属性

  3. prototype 的属性

  4. 实例的属性


详解1:构造器

首先我们应当知道,Symbol 可以作为一个函数使用,有一个可选参数,并且会执行以下 4 步:

  1. 如果作为构造器,使用 new 调用,应当抛出类型错误。

  2. 如果为传递参数,将描述文字设置为 undefined。

  3. 否则将参数转换成字符串作为描述文字。

  4. 返回一个唯一的 Symbol 值,它的 Description 设置为上面的描述文字。

值得注意的是 __Description__ 和 __Name__ 实际上都不应当被外界访问到,应作为私有属性。此外,为了使 constructor 的 name 显示 Symbol 我们将简单的进行一下处理。根据这些我们可以实现以下代码,:

const SymbolPolyfill = function Symbol (description) {
  if (this instanceof SymbolPolyfill) throw new TypeError('Symbol is not a constructor')

  let descString
  description === undefined ? descString = undefined : descString = String(description)

  let symbol = Object.create(SymbolPolyfill.prototype)

  defineProperties(symbol, {
    __Description__: d('c', descString),
    __Name__: d('c', generateName(descString)),
  })

  return symbol
}

其中有一个 generateName 函数,用来为每一个 Symbol 生成唯一的名字。

它的一个简单实现版本如下:

const generateName = (function () {
  const created = {}

  return function (description, internal) {
    const postfix = created[description] =  created[description] === undefined ?  1 : created[description] + 1
    return `@@${ description || '' }${ postfix }`
  }
}())

这里用到了一个简单的闭包,来避免外界访问 created 这一对象。created 是用来记录已生成的 symbol 的。


详解2:构造器的属性

接下来,我们要设置构造器的属性。为了方便设置每个属性的 descriptor,我们先封装一个 d 函数,它可以方便我们进行快速的设置属性的 descriptor。

构造器的属性分为 3 种:

  1. 函数:如 for 和 keyFor

  2. 对象:prototype

  3. symbol:其他

其中,for 是在全局注册了一个 symbol,keyFor 是取得这个 symbol 的 key,它们的实现较为简单,值得注意的是,规范中是用 List 来实现的,但是我们这里可以直接利用 JavaScript 的遍历性,使用 Object 作为一个映射,从而方便的进行查找等操作:

const GlobalSymbolRegistry = {}

defineProperties(SymbolPolyfill, {
  for: d('cw', function (key) {
    key = String(key)
    return GlobalSymbolRegistry[key] ? GlobalSymbolRegistry[key] : GlobalSymbolRegistry[key] = SymbolPolyfill(key)
  }),
  keyFor: d('cw', function (symbol) {
    for (let key in globalSymbols) {
      if (globalSymbols[key] === symbol) return key
    }
  }),
  prototype: d('', SymbolPolyfill.prototype),
})

其他内置的 symbol,可以使用如下方式优雅的添加:

const INTERNAL = {
  hasInstance: true,
  isConcatSpreadable: true,
  iterator: true,
  match: true,
  replace: true,
  search: true,
  species: true,
  split: true,
  toPrimitive: true,
  toStringTag: true,
  unscopables: true,
}

Object
  .keys(INTERNAL)
  .forEach(key => {
    defineProperty(SymbolPolyfill, key, d('', SymbolPolyfill(key)))
  })

这里我们想要内置 symbol 的名字可以直接是 @@xxxx,如 @@match,这时候,我们可以对 generateName 进行进一步的改造:

const generateName = (function () {
  const created = {}

  return function (description, internal) {
    let postfix
    if (INTERNAL[description]) {
      postfix = ''
      INTERNAL[description] = false
    } else {
      postfix = created[description] =  created[description] === undefined ?  1 : created[description] + 1
    }
    return `@@${ description || '' }${ postfix }`
  }
}())

详解3:prototype 的属性

prototype 上的属性共有 5 个我们将一一介绍:

  1. constructor

    由于我们的构造器本身是个 function,它上面自身就带有 prototype,且 prototype 的 constructor 指向自身,所以在这无需特别设置。

  2. toString

    本身,toString 应当设置为返回 Symbol(description),但是由于我们无法真正的创造原始数据类型,我们创造的 symbol 本身其实是一个对象,无法作为属性的键使用,因此,我们这里进行特殊处理,返回其 __Name__ 作为唯一标识符。

    defineProperties(SymbolPolyfill.prototype, {
      // toString: d('cw', function () { return `Symbol(${ validateSymbol(this).__Description__ || '' })` }),
      toString: d('cw', function () { return validateSymbol(this).__Name__; }),
    })
    

    其中,我们用到了 validateSymbol ,它是我们实现用来对 Symbol 进行判断处理的。

    function isSymbol (value) {
      if (!value) return false
      if (typeof value !== 'object') return false
      if (!value.constructor) return false
      if (value.constructor.name !== 'Symbol') return false
      if (!value.__SymbolData__ || value.__SymbolData__ !== value) return false
      if (value[value.constructor.toStringTag] !== 'Symbol') return false
      return true
    }
    
    function validateSymbol (value) {
      if (!isSymbol(value)) return new TypeError(value + 'is not a symbol')
      return value
    }
    
  3. valueOf

    valueOf 理论上也应当返回原始数据类型,但是实际上也无法实现,但根据规范就是应当返回 __SymbolData-_ 的值。

    defineProperties(SymbolPolyfill.prototype, {
      valueOf: d('cw', function () { return validateSymbol(this).__SymbolData__ }),
    })
    
  4. [@@toPrimitive]

    这个键名其实表示的是 Symbol.toPrimitive 对应的 @@toPrimitive 这一 symbol,它将返回 __SymbolData-_ 的值。

    defineProperty(SymbolPolyfill.prototype, SymbolPolyfill.toPrimitive, d('c', function () { return validateSymbol(this).__SymbolData__} ))
    
  5. [@@toStringTag]

    这个键名其实表示的是 Symbol.toStringTag 对应的 @@toStringTag 这一 symbol,它的值是字符串 Symbol。

    defineProperty(SymbolPolyfill.prototype, SymbolPolyfill.toStringTag, d('c', 'Symbol'))
    

详解4:实例属性

Symbol 的实例像是普通的 Object,所以它的属性都是继承自 Symbol.prototype 的,此外还有一个 SymbolData,它对应着 Symbol 所对应的对象,因此我们修改一下构造器:

const SymbolPolyfill= function Symbol (description) {
  if (this instanceof SymbolPolyfill) throw new TypeError('Symbol is not a constructor')

  let descString
  description === undefined ? descString = undefined : descString = String(description)

  let symbol = Object.create(SymbolPolyfill.prototype)

  defineProperties(symbol, {
    __Description__: d('c', descString),
    __Name__: d('c', generateName(descString)),
    __SymbolData__: d('c', symbol),
  })

  return symbol
}

细节

这里有个要注意的细节,symbol instanceOf Symbol 应当返回 false ,为了实现这一点,我们需要额外一个 HiddenSymbol 。

const HiddenSymbol = function Symbol (description) {
  if (this instanceof HiddenSymbol) throw new TypeError('Symbol is not a constructor')
  return SymbolPolyfill(description)
}

const SymbolPolyfill= function Symbol (description) {
  if (this instanceof SymbolPolyfill) throw new TypeError('Symbol is not a constructor')

  let descString
  description === undefined ? descString = undefined : descString = String(description)

  let symbol = Object.create(HiddenSymbol.prototype)

  defineProperties(symbol, {
    __Description__: d('c', descString),
    __Name__: d('c', generateName(descString)),
    __SymbolData__: d('c', symbol),
  })

  return symbol
}

但是这样的话,toString 和 prototype.constructor 会出现问题,因此仍然要再次进行处理:

defineProperties(HiddenSymbol.prototype, {
  constructor: d('cw', SymbolPolyfill),
  toString: d('cw', function () { return this.__Name__; }),
})

值得注意的是,这样子的话,SymbolPolyfill 本身的 toString 方法将不会被访问,不如将它设置回规范要求的样子。

defineProperties(SymbolPolyfill.prototype, {
  toString: d('cw', function () { return `Symbol(${ validateSymbol(this).__Description__ || '' })` }),
  valueOf: d('cw', function () { return validateSymbol(this).__SymbolData__ }),
})

最后,补全其他 prototype 上的方法:

defineProperty(HiddenSymbol.prototype, SymbolPolyfill.toStringTag, d('c', SymbolPolyfill.prototype[SymbolPolyfill.toStringTag]));
defineProperty(HiddenSymbol.prototype, SymbolPolyfill.toPrimitive, d('c', SymbolPolyfill.prototype[SymbolPolyfill.toPrimitive]));

无法实现的点

有一些点,受限于语言本身以及笔者水平等原因暂时无法实现:

  1. typeof

    由于无法创建真正的原始数据类型,也就无法让 typeof 显示 symbol。

  2. console.log

    笔者原先以为,console.log 将会调用 toString 或者 valueOf 方法,后来查阅规范发现并非如此,因此只好作罢。

  3. JSON.stringify

    理论上,JSON.stringify 将会过滤掉 Symbol 作为键名的属性,但是我们并没有真正的实现原始数据类型 Symbol,因此它其实是转换成字符串后作为键名的,因此也无法实现。

  4. string 隐性转换

    理论上,Symbol 被隐形转换成字符串时应当报错,但是,笔者并不知道如何判断隐形转换还是显性转换,因此作罢。

  5. number 转换

    原因同上。


Symbol 的应用

  1. for…of

    Symbol 的一个典型应用是用于 for…of ,它将根据要遍历对象中 Symbol.iterator 的实现来进行处理。MDN 给的一个典型例子是:

    var iterable = {
      [Symbol.iterator]() {
        return {
          i: 0,
          next() {
            if (this.i < 3) {
              return { value: this.i++, done: false };
            }
            return { value: undefined, done: true };
          }
        };
      }
    };
    
    for (var value of iterable) {
      console.log(value);
    }
    // 0
    // 1
    // 2
    

  2. vuex mutation-types

    我们知道在 vuex 中管理 mutation 的类型通常需要使用如下的方式:

    const SOME_MUTATION = 'SOME_MUTATION'
    

    有了 Symbol 我们可以这样写:

    const SOME_MUTATION = Symbol()
    

小结

本文简单的实现了一个 Symbol Polyfill,相信读者可以通过本文对 Symbol 有一个更为深刻的理解。


相关链接

1. 规范

2. MDN 文档

3. Github 已有的实现

4. 本文完整实现