类型转换
字符串转换
1 | String(false); // 'false' |
数字型转换
1 | Number(undefined); // NaN |
布尔值转换
直观上为“空”的值,都转换为false。
包括 0, null, undefined, NaN, ""
数学运算
- 加
- 减
- 乘
- 除
- 取余
%
- 求幂
**
主要是求幂,之前都是用的 Math.pow
来计算的。现在可以使用 **
了。
1 | Math.pow(4, 2); // 14 |
自增/自减
运算符 ++
和 -
可以置于变量前,也可以置于变量后。
- 当运算符置于变量后,被称为“后置形式”:
counter++
。 - 当运算符置于变量前,被称为“前置形式”:
++counter
。
逗号运算符
1 | let a = (1 + 2, 3 + 4); // 7 |
,
运算符优先级相当的低,比 =
还低。所以,这里使用括号包裹。使用括号包裹以后,会选择括号中的最后一个元素作为括号运算的返回值。
值的比较
1 | let a = 0; |
JavaScript 会把待比较的值转化为数字后再做比较(因此 “0” 变成了 0)。若只是将一个变量转化为 Boolean 值,则会使用其他的类型转换规则。
1 | null === undefined; // false |
1 | null > 0; // false(1) |
为什么会出现这种反常结果,这是因为相等性检查 ==
和普通比较符 > < >= <=
的代码逻辑是相互独立的。进行值的比较时,null
会被转化为数字,因此它被转化为了 0
。这就是为什么(3)中 null >= 0
返回值是 true,(1)中 null > 0
返回值是 false。
另一方面,undefined
和 null
在相等性检查 ==
中不会进行任何的类型转换,它们有自己独立的比较规则,所以除了它们之间互等外,不会等于任何其他的值。这就解释了为什么(2)中 null == 0
会返回 false。
1 | undefined < 0; //false(1) |
原因如下:
(1)
和(2)
都返回false
是因为undefined
在比较中被转换为了NaN
,而NaN
是一个特殊的数值型值,它与任何值进行比较都会返回false
。(3)
返回false
是因为这是一个相等性检查,而undefined
只与null
相等,不会与其他值相等。
避免一些奇怪的问题出现:
- 除了严格相等
===
外,其他但凡是有undefined/null
参与的比较,我们都需要格外小心。 - 除非你非常清楚自己在做什么,否则永远不要使用
>= > < <=
去比较一个可能为null/undefined
的变量。对于取值可能是null/undefined
的变量,请按需要分别检查它的取值情况。
空值合并运算符 ??
这个有点像是三目运算,如果三目运算的条件不是 null
或者 undefined
1 | result = (a !== null && a !== undefined) ? a : b; |
关于注释
糟糕的注释
代码中“解释性”注释的数量应该是最少的。什么是“解释性”注释?像下面这样
1 | // 这里的代码会先做这件事(……)然后做那件事(……) |
好的代码就算么有注释,代码也应该很容易理解。
关于这一点有一个很棒的原则:“如果代码不够清晰以至于需要一个注释,那么或许它应该被重写。”
方法一:分解函数
有时候,用一个函数来代替一个代码片段是更好的,就像这样:
1 | function showPrimes(n) { |
更好的变体,使用一个分解出来的函数 isPrime
:
1 | function showPrimes(n) { |
现在我们可以很容易地理解代码了。函数自己就变成了一个注释。这种代码被称为 自描述型 代码。
通常,一个函数只做一件事,记住这一点,那么代码将会更容易阅读。可能你会觉得写起来的时候很复杂,但是,如果真这样做了。将来如果修改代码,添加功能,你会庆幸之前的做法。这一点,我还需要提升啊。
方法二:创建函数
如果我们有一个像下面这样很长的代码块:
1 | // 在这里我们添加威士忌 |
我们像下面这样,将上面的代码重构为函数,可能会是一个更好的变体:
1 | addWhiskey(glass); |
同样,函数本身就可以告诉我们发生了什么。没有什么地方需要注释。并且分割之后代码的结构也更好了。每一个函数做什么、需要什么和返回什么都非常地清晰。
我看到这里的时候就觉得似乎明白了什么。这不就是我以前经常干的事情么。想到这就是一个简单的 for
循环,搞一个函数搞毛线啊。现在这样一看,确实更加清晰明了了。
但是代码中解释性注释总是不可避免的,比如一些复杂的算法。总之,我们要尽量的使用自我描述性代码。
像上面的创建函数,我们可以给函数的参数,以及函数的作用添加上一些简单的注释,像下面这样:
1 | /** |
这种结构的注释可以通过一些编辑器的插件实现,比如
Document This - Visual Studio Marketplace
或者其他。
这种语法叫做 JSDoc:用法、参数和返回值。
对象
对象中在进行遍历的时候,如果所有的属性都是字符串类型的
1 | let user = { |
上面看到,打印出来的属性是按照创建时的顺序来排序。这里准确的说是:“如果属性名不是整数”。
整数属性是什么呢?这里的“整数属性”指的是一个可以在不做任何更改的情况下与一个整数进行相互转换的字符串。
1 | // Math.trunc 是内置的去除小数部分的方法。 |
所以,“49” 是一个整数属性名,因为我们把它转换成整数,再转换回来,它还是一样的。但是 “+49” 和 “1.2” 就不行了。
那如果是整数属性呢?
1 | let codes = { |
会看到这是按照升序排列的。那如果我也要按照创建时候的顺序排序该怎么办呢?
1 | let codes = { |
在前面添加一个一元运算符 +
就OK啦。
对象的拷贝
如果是普通的对象,即对象下面的属性只是单纯的基本属性而非引用属性,可以使用 for...in
或者是 Object.assign
来进行拷贝。
1 | let user = { |
1 | let user = { name: "John" }; |
如果被拷贝的属性的属性名已经存在,那么它会被覆盖:
1 | let user = { name: "John" }; |
这是浅拷贝,无法拷贝属性是其他对象引用的情况。
如果要实现深拷贝,可以用递归来实现。或者不自己造轮子,使用现成的实现,例如 JavaScript 库 [lodash](https://lodash.com/)
中的 _.cloneDeep(obj)。
关于垃圾回收机制
JavaScript 中主要的内存管理概念是 可达性。
简而言之,“可达”值是那些以某种方式可访问或可用的值。它们一定是存储在内存中的。
这里列出固有的可达值的基本集合,这些值明显不能被释放。
比方说:
当前函数的局部变量和参数。
嵌套调用时,当前调用链上所有函数的变量与参数。
全局变量。
(还有一些内部的)
这些值被称作 根(roots)。
如果一个值可以通过引用或引用链从根访问任何其他值,则认为该值是可达的。
JavaScript 中对于垃圾回收的机制可以简单用 mark and sweep(标记清扫)
来理解。
下面是一个家庭
1 | function marry(man, woman) { |
marry
函数通过让两个对象相互引用使它们“结婚”了,并返回了一个包含这两个对象的新对象。
由此产生的内存结构:
图中的箭头可以理解成上面说到的 可达。箭头指向的这个对象就是可达的,那么他就不会被回收。
现在删除两个引用:
1 | delete family.father; |
仅删除这两个引用中的一个是不够的,因为所有的对象仍然都是可达的。
但是,如果我们把这两个都删除,那么我们可以看到再也没有对 John 的引用了:
对外引用不重要,只有传入引用才可以使对象可达。所以,John 现在是不可达的,并且将被从内存中删除,同时 John 的所有数据也将变得不可达。
经过垃圾回收:
如果上面的代码,我们把 family 删除:
1 | family = null; |
内存内部状态将变成:
这个例子展示了可达性概念的重要性。
显而易见,John 和 Ann 仍然连着,都有传入的引用。但是,这样还不够。
前面说的 "family"
对象已经不再与根相连,没有了外部对其的引用,所以它变成了一座“孤岛”,并且将被从内存中删除。
构造器与 new
关于 new
可能在面试中会问到你他的原理,并要求你自己写一个new。
当一个函数被使用 new
操作符执行时,它按照以下步骤:
- 一个新的空对象被创建并分配给
this
。 - 函数体执行。通常它会修改
this
,为其添加新的属性。 - 返回
this
的值。
1 | function User(name) { |
new.target
老实说,这东西,我不知道,也没用过。
1 | function User() { |
他的用处就是检查函数是否被使用 new 进行调用了。
构造器的 return
通常,构造器没有 return
语句。它们的任务是将所有必要的东西写入 this
,并自动转换为结果。
但是,如果这有一个 return
语句,那么规则就简单了:
- 如果
return
返回的是一个对象,则返回这个对象,而不是this
。 - 如果
return
返回的是一个原始类型,则忽略。
可选链 ?.
可选链是ES2020新引入的,是一种访问嵌套对象属性的安全的方式。即使中间的属性不存在,也不会出现错误。
通常我们在使用对象的数据的时候:
1 | let user = {}; // 变量 user 没有 "address" 属性 |
上面直接使用会出错,我们的代码中会这样处理
1 | alert( user && user.address && user.address.street ); // undefined(不报错) |
这是以前的写法,还挺麻烦的。如果有了可选链呢?
1 | let user = {}; // user 没有 address |
如果可选链 ?. 前面部分是 undefined 或者 null,它会停止运算并返回 undefined。
上面的代码虽说是可以的,但是我们也不要过度的使用可选链。看看上面这句话,前面部分是 undefined 或者 null。说明 ?. 前面的那部分我们不确定有没有。这里的 user 我们是确定存在的,而且是一个对象,所以,可以这样处理:
1 | let user = {}; // user 没有 address |
可选链函数数组调用
函数调用
1 | let user1 = { |
数组调用
1 | let user1 = { |
Symbol
Symbol 是一种新的数据类型。
根据规范,对象的属性键只能是字符串类型或者 Symbol 类型。不是 Number,也不是 Boolean,只有字符串或 Symbol 这两种类型。
“Symbol” 值表示唯一的标识符。
1 | let id1 = Symbol("id"); |
Symbol 不会自动的转换为字符串。
1 | let id = Symbol("id"); |
这是一种防止混乱的“语言保护”,因为字符串和 Symbol 有本质上的不同,不应该意外地将它们转换成另一个。
如果我们真的想显示一个 Symbol,我们需要在它上面调用 .toString()
,如下所示:
1 | let id = Symbol("myId"); |
或者获取 symbol.description
属性,只显示描述(description)
1 | let id = Symbol("myId"); |
Symbol 不能使用 for...in
和 Object.assign
遍历获得。
全局的 Symbol
上面说到 Symbol 是唯一的,是指通过 Symbol()
这种方式创建的。还有一种创建方式是 Symbol.for()
,这种方式创建的全局的 Symbol。
1 | // 从全局注册表中读取 |
对于全局 Symbol 除了使用 description
属性获取,还可以通过 Symbol.keyFor
。
1 | let globalSymbol = Symbol.for("name"); |
对象下的 Symbol 属性
1 | const object1 = {}; |
前面说到了 Symbol 不能使用 for...in
和 Object.assign
遍历获得。
1 | for (let key in object1) { |
那么如果要获取对象下是否有以及有多少 Symbol 属性时呢?使用 getOwnPropertySymbols
方法
1 | const object1 = {}; |
还可以使用 Reflect.ownKeys()
1 | const a = Symbol('a'); |
注意了:普通字符串的顺序排在 Symbol 之前哦。
系统的 Symbol
JavaScript 内部有很多的系统 Symbol,可以在 [Symbol规范表](https://tc39.es/ecma262/#sec-well-known-symbols)
中看到
Symbol.hasInstance
Symbol.isConcatSpreadable
Symbol.iterator
Symbol.toPrimitive
- …等等
对象 — 原始值转换
思考一下这样的情况:
当对象相加 obj1 + obj2
,相减 obj1 - obj2
,或者使用 alert(obj)
打印时会发生什么?
在这种情况下,对象会被自动转换为原始值,然后执行操作。
在前面的类型转换中,提到了数字,字符串,布尔值的类型转换,但是没有涉及到对象的转换规则。
- 所有的对象在布尔上下文(context)中均为
true
。所以对于对象,不存在 to-boolean 转换,只有字符串和数值转换。 - 数值转换发生在对象相减或应用数学函数时。例如,
Date
对象可以相减,date1 - date2
的结果是两个日期之间的差值。 - 至于字符串转换 —— 通常发生在我们像
alert(obj)
这样输出一个对象和类似的上下文中。
ToPrimitive(到原始位置)
我们可以使用特殊的对象方法,对字符串个数值转换进行微调。
下面是三个类型转换的变体,被称为 “hint”,在 规范 中有详细介绍(译注:当一个对象被用在需要原始值的上下文中时,例如,在 alert 或数学运算中,对象会被转换为原始值):
string
对象到字符串的转换,当我们对期望一个字符串的对象执行操作时
number
对象到数字的转换
default
在少数情况下发生,当运算符“不确定”期望值的类型时。
为了进行转换,JavaScript 尝试查找并调用三个对象方法:
- 调用
obj[Symbol.toPrimitive](hint)
—— 带有 symbol 键Symbol.toPrimitive
(系统 symbol)的方法,如果这个方法存在的话, - 否则,如果 hint 是
"string"
—— 尝试obj.toString()
和obj.valueOf()
,无论哪个存在。 - 否则,如果 hint 是
"number"
或"default"
—— 尝试obj.valueOf()
和obj.toString()
,无论哪个存在。
Symbol.toPrimitive(hint)
有一个名为 Symbol.toPrimitive 的内建 symbol,它被用来给转换方法命名,像这样:
1 | let user = { |
这个方法可能见的不是很多
toString/valueOf
方法 toString
和 valueOf
来自上古时代。它们不是 symbol(那时候还没有 symbol 这个概念),而是“常规的”字符串命名的方法。它们提供了一种可选的“老派”的实现转换的方法。
如果没有 Symbol.toPrimitive
,那么 JavaScript 将尝试找到它们,并且按照下面的顺序进行尝试:
- 对于 “string” hint,
toString -> valueOf
。 - 其他情况,
valueOf -> toString
。
这些方法必须返回一个原始值。如果 toString
或 valueOf
返回了一个对象,那么返回值会被忽略(和这里没有方法的时候相同)。
默认情况下,普通对象具有 toString
和 valueOf
方法:
toString
方法返回一个字符串"[object Object]"
。valueOf
方法返回对象自身。
1 | let user = {name: "John"}; |
下面的实现就和上面使用 Symbol.toPrimitive
的结果相同了
1 | let user = { |
如果没有 Symbol.toPrimitive
和 valueOf
,toString
将处理所有原始转换。
1 | let user = { |
由于历史原因,如果 toString
或 valueOf
返回一个对象,则不会出现 error,但是这种值会被忽略(就像这种方法根本不存在)。这是因为在 JavaScript 语言发展初期,没有很好的 “error” 的概念。
相反,Symbol.toPrimitive
必须 返回一个原始值,否则就会出现 error。
所以这部分来个总结
对象到原始值的转换,是由许多期望以原始值作为值的内建函数和运算符自动调用的。
这里有三种类型(hint):
"string"
(对于alert
和其他需要字符串的操作)"number"
(对于数学运算)"default"
(少数运算符)
规范明确描述了哪个运算符使用哪个 hint。很少有运算符“不知道期望什么”并使用 "default"
hint。通常对于内建对象,"default"
hint 的处理方式与 "number"
相同,因此在实践中,最后两个 hint 常常合并在一起。
转换算法是:
- 调用
obj[Symbol.toPrimitive](hint)
如果这个方法存在, - 否则,如果 hint 是
"string"
- 尝试
obj.toString()
和obj.valueOf()
,无论哪个存在。
- 尝试
- 否则,如果 hint 是
"number"
或者"default"
- 尝试
obj.valueOf()
和obj.toString()
,无论哪个存在。
- 尝试
在实践中,为了便于进行日志记录或调试,对于所有能够返回一种“可读性好”的对象的表达形式的转换,只实现以 obj.toString()
作为全能转换的方法就够了。
一道面试题
问:如何让 a==1&&a==2&&a==3
的值为true?
通过上面对象原始值转换的知识点,可以有以下三种方法了:
1 | let a = { value : 0 }; |
1 | let a = { value : 0 }; |
1 | let a = { value : 0 }; |