前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

ES6系列之Symbol的唯一性与内置符号

首页2018-12-21 15:00:21Front-End
ES6JavaScriptSymbol数据类型

给别人的对象上挂一个属性,这件事听着简单,做起来其实一直有风险。你写一个日志库,想在用户传进来的对象上标记一个 __logged__,怎么保证用户自己没用过这个名字?加下划线、加前缀、加随机数,都是概率游戏,赌的是不撞车。

Symbol 就是来终结这个赌局的。它生成的值天生独一无二,拿它当属性名,谁也覆盖不了谁。更有意思的是,语言自己也在用这套机制,for...of 能遍历什么、instanceof 怎么判断、Object.prototype.toString 输出什么标签,背后全是几个内置 Symbol 在做钩子。这篇把 Symbol 的基本行为和这套「协议钩子」的设计一起讲清楚。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • Symbol 作为原始类型的定位,以及它和 BigInt 的先后关系
  • 唯一性怎么体现,为什么不能用 new
  • Symbol.for 的全局注册表和普通 Symbol 的区别
  • 内置符号(well-known symbols)是怎么当协议钩子用的
  • Symbol 属性在各种遍历方式下的可见性对照
  • 什么场景真的该用 Symbol,什么场景是过度设计

# 一、简介

ES6 新加入了一种原始数据类型 Symbol,表示独一无二的值。在 ES6 那个时间点,它是 JS 的第七种数据类型,前六种是 Undefined、Null、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。

这里补一句时效性说明。原文写于 2018 年,那会儿说「第七种」是准确的。后来 ES2020 又加了 BigInt,所以现在的完整清单是七种原始类型(Undefined、Null、Boolean、String、Number、Symbol、BigInt)加上 Object。数数的说法变了,Symbol 本身的行为没变。

对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的 Symbol 类型。凡是属性名属于 Symbol 类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。

# 1.1 定义

Symbol([description])
@前端进阶之旅: 代码已经复制到剪贴板

参数 description 是一个可选参数,是一个字符串,可以用于调试,但不能通过它访问到 Symbol 自身。

var sym1 = Symbol();
var sym2 = Symbol('foo');
var sym3 = Symbol('foo');
@前端进阶之旅: 代码已经复制到剪贴板

description 纯粹是给人看的。你在 DevTools 里看到 Symbol(userId),能一眼知道这是干嘛的,仅此而已,代码逻辑一点都碰不到它。ES2019 之后可以用 sym.description 直接读回这个字符串,比 sym.toString().slice(7, -1) 那种土办法干净不少。

# 1.2 值唯一性

每一个 Symbol() 返回的值都是唯一的。一个 Symbol 值能作为对象属性的标识符,这是该数据类型仅有的目的。

Symbol("yuan") === Symbol("yuan"); // false
@前端进阶之旅: 代码已经复制到剪贴板

原文这里写的是「这是改数据类型仅有的目的」,「改」应为「该」,顺手改了。

两个描述一模一样的 Symbol 依然不相等,这就是唯一性的全部含义。回到开头那个日志库的问题,现在解法很干净:

// logger.js
const LOGGED = Symbol('logged');

export function markLogged(obj) {
  obj[LOGGED] = true;
}
export function isLogged(obj) {
  return obj[LOGGED] === true;
}
@前端进阶之旅: 代码已经复制到剪贴板

LOGGED 这个值只有你的模块持有,用户就算自己也写了 Symbol('logged'),那也是另一个值,两边井水不犯河水。

# 1.3 不可以使用 new 操作符

var sym = new Symbol(); // TypeError 报错
@前端进阶之旅: 代码已经复制到剪贴板

原因很直白,Symbol 是原始类型,不是对象。new 的语义是造一个对象出来,跟原始类型天然冲突。Number、String、Boolean 之所以能 new(虽然没人这么写),是历史包袱,Symbol 是新加的,标准直接把这条路封了。

# 1.4 结合 Object() 函数

结合 Object() 函数,可以创建一个 Symbol 包装器对象。

var sym = Symbol();
typeof sym;  // "symbol"
var symobj = Object(sym);
typeof symobj; // "object"
@前端进阶之旅: 代码已经复制到剪贴板

原文这一行的注释末尾混进了两个全角弯引号字符,已经清理掉。这类字符在 markdown 双引擎渲染下容易出乱码,属于顺手要修的东西。

包装对象日常几乎用不上,知道 typeof 会从 "symbol" 变成 "object" 就行,免得类型判断的时候被绕进去。

# 1.5 全局共享 Symbol

使用 Symbol.for() 方法会根据给定的键 key,从运行时的 symbol 注册表中找到对应的 symbol。如果找到了就返回它,否则新建一个与该键关联的 symbol,并放入全局 symbol 注册表中。

这一条和 1.2 的唯一性看起来矛盾,其实是两套不同的用途,第三节会展开讲。

# 1.6 在对象中查找 Symbol 属性

var obj = {};
var a = Symbol("a");
var b = Symbol.for("b");

obj[a] = "localSymbol";
obj[b] = "globalSymbol";

var objectSymbols = Object.getOwnPropertySymbols(obj);

console.log(objectSymbols)         // [Symbol(a), Symbol(b)]
@前端进阶之旅: 代码已经复制到剪贴板

注意 Object.keys(obj) 在这里会返回空数组。Symbol 属性不参与常规遍历,想拿到它们只有 Object.getOwnPropertySymbols 和 Reflect.ownKeys 两条路。这个特性有好有坏,第四节专门对照。

# 二、内置符号:语言自己开的钩子

这一节是 Symbol 里最有意思的部分。除了让你造独一无二的属性名,标准还预置了一批 Symbol,挂在 Symbol 对象上,用来让你改写语言层面的默认行为。它们有个专门的名字,叫 well-known symbols。

# 2.1 length 属性

// Symbol 函数本身的 length 为 0
Symbol.length // 0
@前端进阶之旅: 代码已经复制到剪贴板

这个跟内置符号无关,只是 Symbol 这个函数对象的 length。函数的 length 表示形参个数,description 是可选参数不计入,所以是 0。

# 2.2 Symbol.iterator 与迭代协议

Symbol.iterator 为每一个对象定义了默认的迭代器。这个迭代器可以被 for...of 循环使用。

// 自定义迭代器
var myIterator = {};
myIterator[Symbol.iterator] = function* () {
    yield 1;
    yield 2;
    yield 3;
};
[...myIterator] // [1, 2, 3]
@前端进阶之旅: 代码已经复制到剪贴板

一个普通对象本来是不能展开的,[...{}] 直接报 object is not iterable。挂上 Symbol.iterator 之后它就成了可迭代对象,for...of、展开运算符、解构赋值、Array.from、Promise.all 全都能吃它。

这就是「协议钩子」的意思。语言不关心你的对象是什么类,只看你有没有实现那个约定的 Symbol 键。用 Symbol 而不是字符串 'iterator' 来当这个键,就是为了避免和用户自己定义的属性撞名。这套用生成器来部署迭代接口的写法,我在 ES6系列之 Generator 函数的暂停与自动执行 里有更完整的展开。

Symbol 属性和 for...in 迭代:

var obj = {};
obj[Symbol("a")] = "a";
obj[Symbol.for("b")] = "b";
obj["c"] = "c";
obj.d = "d";

for (var i in obj) {
   console.log(i);
}
// "c"
// "d"
@前端进阶之旅: 代码已经复制到剪贴板

两个 Symbol 键被完全跳过了。

# 2.3 正则相关的四个符号

这一组符号用于标识对象是否具有正则表达式的行为。Symbol.match 表示对象是否具有指定的匹配的正则表达式。

"/bar/".startsWith(/bar/);
// Throws TypeError,因为 /bar/ 是一个正则表达式
// 且 Symbol.match 没有修改
@前端进阶之旅: 代码已经复制到剪贴板

startsWith 这类方法会先检查参数上有没有 Symbol.match,有就认为它是正则,直接抛 TypeError。这是标准刻意留的一道保险,防止你把正则当字符串误用。

如果你将 Symbol.match 置为 false,使用 match 属性的表达式检查会认为该对象不是正则表达式对象,startsWith 和 endsWith 方法将不会抛出 TypeError。

var re = /foo/;
re[Symbol.match] = false;

"/foo/".startsWith(re); // true
"/baz/".endsWith(re);   // false
@前端进阶之旅: 代码已经复制到剪贴板

这两行的结果值得解释一下。关掉 Symbol.match 之后,re 被当成普通对象,参数会被转成字符串,String(/foo/) 就是 "/foo/"。所以第一行相当于 "/foo/".startsWith("/foo/"),为 true;第二行相当于 "/baz/".endsWith("/foo/"),为 false。

另外三个是 Symbol.replace、Symbol.search、Symbol.split,分别指定了 String.prototype.replace()、String.prototype.search()、String.prototype.split() 在遇到这个对象时调用的方法。改写它们你就能造出一个「行为像正则但不是正则」的东西,日常业务基本用不到,但看到某些库里有奇怪的字符串处理行为时,可以往这里查。

# 2.4 hasInstance 与 toStringTag

Symbol.hasInstance 是一个用来确定构造器对象识别的对象是否为它的实例的方法。它决定的其实就是 instanceof 怎么判。

class Even {
  static [Symbol.hasInstance](num) {
    return Number(num) % 2 === 0;
  }
}
console.log(2 instanceof Even);  // true
console.log(3 instanceof Even);  // false
@前端进阶之旅: 代码已经复制到剪贴板

Symbol.toStringTag 用于对象的默认描述的字符串值,也就是 Object.prototype.toString() 输出的那个尾巴。

fe
  • 一、简介
    • 1.1 定义
    • 1.2 值唯一性
    • 1.3 不可以使用 new 操作符
    • 1.4 结合 Object() 函数
    • 1.5 全局共享 Symbol
    • 1.6 在对象中查找 Symbol 属性
  • 二、内置符号:语言自己开的钩子
    • 2.1 length 属性
    • 2.2 Symbol.iterator 与迭代协议
    • 2.3 正则相关的四个符号
    • 2.4 hasInstance 与 toStringTag
  • 三、静态方法与全局注册表
    • 3.1 Symbol.for(key)
    • 3.2 Symbol.keyFor(sym)
  • 四、遍历时的可见性
  • 五、什么时候真的该用 Symbol
  • 总结
  • 参考

← ES6系列之Generator函数的暂停与自动执行ES6系列之装饰器的原理与实战用法 →