重学JavaScript系列——(三)语言基础
博主以扎实JavaScript基础为目的,以《JavaScript高级程序设计(第四版)》为核心参考资料,以一个“复习者”的角度有针对性地来创作这期专栏。文章加入了博主的很多思考和开发经验,关注初学JavaScript时容易忽略的地方,着重总结了ECMAScript新标准知识点的特性和应用场景。最终,本专栏将覆盖完整的JavaScript知识体系,以辅佐各路豪杰在开发路上的稳步前进。
专栏传送门:https://blog.csdn.net/huoyihengyuan/category_10586561.html
全文大约1.6w字,建议先收藏,方便阅读和回顾。
编程语言是描述的是在最基本的层面上应该如何工作,涉及到语法、操作符、数据类型以及内置功能。这些都是在我们日常开发中最熟悉的老朋友,毕竟以后还要并肩作战很久,不妨再重新认识一下他们。
接下来的内容主要基于ES6。
3.1 语法
3.1.1 大小写
ECMAScript中的一切都区分大小写。 不论是变量、函数名还是操作符。Test和test是两个不同的变量,Typeof是一个有效的函数名。
3.1.2 标识符
人名、地名等等都叫做名字,在编程的世界里,变量名、函数名、属性名、函数参数名统称为标识符。
标识符有以下要求:
- 第一个字符:字母、下划线"_"、美元号"$"
- 剩下的字符:字母、数字、下划线"_"、美元号"$"
注意这个字母不仅包括26个英文字母的大小写,还包括Unicode字母字符(如À和Æ),但不推荐使用,因为同事的拳头太疼。
按照惯例,ECMAScript的标识符推荐用驼峰大小写形式,即第一个单词的首字母小写,后面的每个单词首字母大写。如:
let myName = 'suyiheng'
let myFirstComputer = 'MAC'
另一种常见的命名方式叫“烤肉串”,形如
my-name
、my-first-computer
,常用于文件名或npm包名。
3.1.3 注释
ECMAScript的注释很简单,只需要:
// 这是单行注释
/*
这是多行注释1
*/
/**
* 这是多行注释2
*/
很多IDE也对注释进行了适配。比如在WebStorm里,按照一定的注释格式,在函数首部的使用多行注释,那么在函数的调用位置,是可以很友好得看到该函数的提示信息的。包括函数含义,参数数据类型,函数返回值类型等等。
3.1.4 严格模式
在严格模式下,ES3不规范的写法会被处理,不安全的活动也会被提示。
我们可以在JavaScript文件或<script>首行加入'use strict'
,代表全局使用严格模式。也可以针对某个函数,在函数体内首行加入'use strict'
来局部使用严格模式。
使用严格模式来书写代码是个好的习惯。
3.1.5 语句
ECMAScript中的语句一般以分号结尾,一般是推荐加分号,但也可以不加分号,因为在AST中也不是根据分号来断句的,这个完全可以取决于开发者的喜好。即使不加分号,不必担心代码压缩问题,因为现在很多的代码压缩工具是会在压缩过程中自动给你补充上对应的分号的。
对于大括号,我们有时候会在if语句下省略大括号,但还是推荐加上的,因为这个位置还是容易出错的。
3.2 关键字和保留字
编程语言中总会有一些特殊单词作为NPC来使用,我们通常称他们为关键字或保留字。
虽然这些词汇不能被作为标识符,但可以作为对象的属性名。可以,但没必要,以确保能兼容过去和未来的ECMAScript版本。
3.3 变量
JavaScript中的变量是松散类型的,每一个变量只是一个可以保存任意值的占位符。我们常用var、let、const来声明变量,let和const只能在ES6中使用。
3.3.1 var和let
let可以视为新一代的var,二者声明变量的方式是一样的。如果不赋初始值,
但是由于变量提升机制,var声明的变量会被提升到函数作用域顶部。在某些情况下,对不熟悉JavaScript的初学者来说,会容易有些赋值相关的误解。而let声明的变量是具有块级作用域的,它的作用域仅限于该块内部。
(1)暂时性死区
let声明变量之前,不能调用这个变量,会抛出RefernceError异常,这就是暂时性死区。(名字很高级)
(2)全局声明
我对于全局作用域中的var声明的变量,是会成为window的属性的,而let不会。
当然,我们也可以不加let和var关键字,来隐式地添加全局变量。
(3)for循环中的let声明
这里有一段代码:
for(var i = 0; i < 5 ; ++i){
setTimeout(() => {console.log(i)} , 0)
}
//你可能会以为输出0,1,2,3,4
//实际上会输出5,5,5,5,5
之所以会这样,是因为var把i提升到循环外层了,而setTimeout会将输出语句放到宏任务队列中,导致在宏任务执行时使用的是最初的i。当把var改为let时,就能输出“0,1,2,3,4”了。这是因为,使用let声明迭代变量时,JavaScript引擎会在后台为每个迭代循环声明一个新的变量,每个setTimeout引用的都是不同的变量实例。
这种行为同样适用于for-in和for-of。
3.3.3 const声明
const声明的变量需要初始化,且不能被修改或重复声明。
这种限制只适用于它指向的变量的引用。换句话说,如果变量引用的是一个对象,那修改这个对象的内部属性并不违反这个限制。
对于变量是对象的情况,可以理解成:
实际上变量保存的是对象的地址,仅修改内部属性并不会改变这个地址值。在JavaScript变量存储机制中,基本数据类型的值都是被保存在栈内存中的,而对象保存在堆内存中,为了能让声明的变量和对象勾搭上,引用类型的变量(对象)就存了对应对象的地址值。
3.3.4 声明风格及最佳实践
奇奇怪怪得var已经让JavaScript社区苦恼了很多年,let和const的出现改善了这种情况。
最后,关于变量声明的最佳实践是:不使用var,优先使用const,let次之。
3.4 数据类型
ECMAScript目前有6种基本数据类型(简单数据类型/原始类型),Undefined
、Boolean
、Null
、Number
、String
、Symbol
(符号)。还有一种复杂的数据类型(引用类型),叫Object
(对象)。像Array、Date、Math等等都是继承的这个对象。
ECMAScript中不能定义自己的数据类型,但包装类型的存在赋予了基本类型更加灵活的功能。
按理说,基本类型不应该有方法,但是我们能直接在
'tom'
上调用一些String类型的方法,就是因为普通字符串被自动包装成为了String类型并操作,这里的String就是包装类型。
3.4.1 typeof操作符
我们可以通过typeof来判断当前值的基本数据类型,像123
被返回number
,这些都很容易理解。但在某些情况下还是会令人费解,但从技术层面上来讲是正确的。比如,typeof null
会返回object
,因为它被认为是一个空对象的引用。
3.4.2 Undefined值
Undefined的值只有undefined
,当用var或者let声明了变量但没有初始化时,就相当于给变量赋值了undefined
。
当初,增加这个特殊值就是为了区别空对象指针(null)和未初始化变量的区别。
虽然未初始化的变量会被自动赋予undefined的值,但我们仍建议在初始化阶段补充上undefined,因为typeof对于未声明的变量也返回undefined。这样,你就会知道那是因为给定的变量未声明,而不是因为声明了但未初始化。
3.4.3 Null类型
Null类型同样只有一个值,是null
。逻辑上讲,它表示一个空对象指针。在定义将来要保存对象值的变量时,建议使用null来初始化而不要用其他值,这样我们就能够直接在if判断时,利用car !== null
来判断当前这个变量有没有被赋值,进而直接操作car这个对象。
undefined是null派生而来的,用==
比较时会返回true。(用===
比较会返回false,这个操作符后面再聊)
所以,可以理解为:null表示“没有对象”,即该处不应该有值;undefined表示“缺少值”,此处应该有一个值,只是没有定义。
3.4.4 Boolean类型
基本的布尔判断true或false我们已经很熟悉了,不过有时候为了简化代码,我们会直接在if括号内判断一个字符串变量或数字变量等等,以简化我们的代码。
比如
let str = 'hello'
if(str){
console.log('out')
}
在这个例子里,“out”会被输出,因为非空字符串会被自动的转化为true。诸如此类,了解更多的这种转化规律可以简化我们的代码,这里为不打算列出表格,因为那只能对临时记忆有效,这里总结一下:
只有当false
、""(空字符串)
、+0
、-0
、NaN
、undefined
、null
这些表示“空“、”无“、”假“的概念都会被Boolean转化为false,剩下的都是true。
由于JavaScript保存数值的方式,实际中可能存在+0和-0。他们在所有情况下都被认为是等同的。
3.4.5 Number类型
通常我们直接写出来的数字被作为十进制整数,来作为变量对应的字面量。
对于八进制和十六进制数,可以加上前导0o
、0x
表示:
let ocatlNum1 = 0o123
let ocatlNum2 = 0o88
let hexNum1 = 0o12A
let hexNum2 = 0o12BA
我们也可以在数字前加上前导0,则0后面数被作为八进制数,如果0后面的数超出了应有的范围,则会被当作十进制数表示。但这种写法不像上面的0o
、0x
一样能够在严格模式中表示,所以开发中还是会以兼容性更高的语法为主。
(1)浮点数
浮点数无非就是在编程语言中小数的一种表示法,我们也常跟他们打交道。但有时候会在看一些别人的源码的时候,遇到一些不常用的浮点数表示法,这边列举和解释一些。
let floatNum1 = 1.1 //常规表示
let floatNum2 = 1. //小数点后没有数字,会被当作整数1来处理
let floatNum3 = 1.0 //小数点后是数字零,会被当作
let floatNum4 = .1 //相当于0.1,有效但不推荐
let floatNum5 = 3.125e7 //科学技术法,相当于31250000,即3.125*(10^7)
因为存储浮点数占用的内存空间是整数的两倍,所以在上面浮点数会优先被考虑转化为整数来处理。
浮点数的准确值最高可达17位,但在算术计算中,由于IEEE754的问题,浮点数的计算远不如整数精确。例如0.1+0.2不等于0.3。
(2)值的范围
由于内存限制,ECMAScript并不能保存这个世界上所有的数值。ECMAScript可以表示的最小的数值是Number.MIN_VALUE中,在大多数浏览器中是5e-324;可以表示的最大数值是Number.MAX_VALUE中,这个值在多数浏览器中是1.7976931348623157e+308。
如果某个值超出了JavaScript可以表示的范围,他就会被表示为Infinity(正无穷)和-Infinity(负无穷)。如果有需要判断一个数是不是在有限大之间,可以直接使用isFinite()函数。
(3)NaN
NaN,是Not a Number的缩写,代表不是一个数值,可以简称“非数值”。
在ECMAScript中,0/0
、+0/-0
等相除都会返回NaN。如果分子是非0,分母是0,则会返回Infinity或-Infinity。
NaN有特殊的属性:
- 任何涉及到NaN的计算都反悔NaN
- NaN == NaN //返回false
为此,ECMAScript提供了isNaN()函数来判断参数是否是“非数值”。该函数会尝试把参数转化为数值,不能转化为数值的值都会导致这个函数返回true。
isNaN(NaN) //true
isNaN(10) //false 10是数值
isNaN("10") //false 可以转化为数值10
isNaN("blue") //true 不可以转化为数值
isNaN(true) //false 可以转化为数值1
(4)数值转换
Number()、parseInt()、parseFloat()都可以将非数值转化为数值,然后他们也有对应的一套复杂的转换规则。
由于规则过于复杂😷,这里我就用最简单的话来总结这套规则:
对于Number():
- 布尔值,true转为1,false转为0。
- 尽可能地把代表“空”的值转为0。如null和空字符串。
- 尽可能地把能合适的字符串转为数字,包括十进制数、十六进制数、浮点数等。
- 把剩下的一堆乱起八糟的字符串,转为NaN。
- 如果传入的是对象,会依次试试valueof()和toString()转化为字符串,再执行上面的步骤。
通常有转化为整数或浮点数的需求时,我们常用parseInt()和parseFloat()来代替Number(),他们会尽可能地将当前参数转为对应的数值。另外,如果传入的是十六进制数,可以在parseInt()的第二个参数传入16即可。
parseFloat()不可以转16进制数。
3.4.6 String类型
String表示0到多个16位Unicode字符序列。我们通常使用双引号("")、单引号(’’)、反单引号(``)都是合法的。
(1)字符字面量
一些非打印字符或其他用途字符,可以用下面的方式表示:
字面量 | 含义 |
---|---|
\n | 换行 |
\t | 制表 |
\b | 退格 |
\r | 回车 |
\f | 换页 |
\\ | 反斜杠( \ ) |
’ | 单引号 |
" | 双引号 |
` | 反单引号 |
\xnn | 以十六进制编码nn表示的字符(其中nn是十六进制数字0-F),例如\x41 等于A |
\unnnn | 以十六进制编码nnnn表示的Unicode字符,例如\u03a3 等于希腊字符Σ |
虽然\u03a3
很长,但通过\u03a3
.length获取长度返回1,算作一个字符。
(2)字符串的特点
ECMAScript中的字符串是不可变得(immutable),修改原始变量中的字符串值,在内存中实际上是销毁了原值,再将新值保存到该变量。
早期的一些浏览器中,字符串拼接很慢就是这个原因,不过现在大部分的浏览器都对这种情况做了不同程度的优化,各位朋友可以放心使用。
(3)转化为字符串
类似于Number,我们可以直接使用String()来尽可能地将参数转化为字符串。
另外,几乎所有的值都能使用toString()方法来代替String(),但null和undefined没有toString()方法,而String()可以将null转化为“null”,undefined同理。
(4)模版字面量
ECMAScript6提供了使用模版字面量定义字符串的能力,可以使用反单引号来跨行定义字符串。
let str1 = `first line
second line`
let str2 = 'first line\nsecond line'
console.log(str1 === str2)//返回true
(5)字符串插值
模版字符串一个常用特性是支持字符串插值,我们可以在模版字符串里面使用${}
来包括一个变量或调用一个函数。
(6)模版字面量标签函数
模版字面量也支持定义标签函数(tag function),目的就是操作模版中的变量,即自定义插值的行为。
举个例子:
let name = 'Tom'//来自用户输入的字符串
let strHtml = `<span>${name}</span>`
在这个例子中,我们未来会将strHtml所表示的标签字符串渲染到DOM中,为了防止XSS攻击,我们可能需要对name进行尖括号字符过滤,这时我们可以这样做。
function simpleTag(strings,...expressions){
//假设已经封装好了这个函数,对不安全的字符进行过滤,并对该ip用户进行警告或封号。
}
let name = 'Tom'//来自用户输入的字符串
let strHtml = simpleTag`<span>${name}</span>`//以此来安全地拼接这个字符串。
functionName``,是标签函数的写法,接受到的参数依次是原始字符串数组和每个表达式求值的结果。
let a = 6
let b = 9
//调用标签函数
simpleTag`${a} + ${b} = ${a+b}`
function simpleTag(strings , aValExpression , bValExpression , sumExpression){
console.log(strings)//[ '', ' + ', ' = ', '' ]
console.log(aValExpression)//6
console.log(bValExpression)//9
console.log(sumExpression)//15
}
(7)原始字符串
其实,String.raw就是一个标签函数,功能是获取原始模版字符串的内容。
例如:
console.log(String.raw`ab\nc`)//ab\nc
3.4.7 Symbol类型
Symbol(符号)是ECMAScript6新引入的基本类型,主要用来充当“唯一标识”。
(1)符号的基本用法
符号需要使用Symbol()来初始化,所有的symbol都不可能相等。
在调用Symbol()时可以传入一个字符串作为“描述”,将来可以通过这个字符串调试代码,但这个字符串和符号定义或标识完全无关,即传入相同字符串的Symbol也不相等。
关于Symbol的作用,例如你使用过Redux状态管理工具,你可能需要创建Action Type来表示action的类型,过去,开发者常裸写具有唯一性的字符串作为Action Type,现在我们可以直接使用Symbol来为表示Action Type的常量赋值。
注意,Symbol()函数不能作为构造函数,即不能被new。如果需要将符号包装成对象,可以把symbol变量作为参数传入Object()的构造函数中。
(2)使用全局符号注册表
在某些情况下,我们需要共享和重用符号实例,可以把他们放到全局中。这里我们通过Symbol.for()来实现。
Symbol.for(key)传入一个键。
区别于Symbol(),Symbol.for()创建的symbol会被放到一个全局symbol注册表中。它会先检查指定key是否已经在注册表中。如果存在,则返回上次存储的那个,否则新创建一个。
所以:
let s1 = Symbol.for('key1')
let s2 = Symbol.for('key1')
console.log(s1 === s2)//true
let s3 = Symbol('key2')
let s4 = Symbol.for('key2')
console.log(s3 === s4)//false
除此之外,还可以使用Symbol.keyFor(newSymbol)来查找对应实例在全局注册表中的指定键。
let newSymbol = Symbol.for('myKey')
console.log(newSymbol)//myKey
(4)使用符号作为属性
过去,我们常用字符串来作为对象的属性,现在也可以使用符号来作为属性。
let s1 = Symbol('key1')
let s2 = Symbol('key2')
let s3 = Symbol('key3')
let s4 = Symbol('key4')
let s5 = Symbol('key5')
let o = { [s1]: 'value1' }
console.log(o)//{Symbol(key1): "value1"}
o[s2] = 'value2'
console.log(o)//{Symbol(key1): "value1",// Symbol(key2): "value2"}
Object.defineProperty(o, s3, { value: 'value3' })
console.log(o)//{Symbol(key1): "value1", Symbol(key2): "value2" ,Symbol(key3): "value3" }
Object.defineProperties(o, {
[s4]: { value: 'value4' },
[s5]: { value: 'value5' },
})
console.log(o)//{Symbol(key1): "value1", Symbol(key2): "value2" ,
// Symbol(key3): "value3", Symbol(key4): "value4",Symbol(key5): "value5"}
既然符号作为属性来存在,那么获取这些属性也需要开发者记住。
- Object.getOwnPropertyNames()–返回对象实例的常规属性数组
- Object.getOwnPropertySymbols()–返回对象实例的符号属性数组
- Reflect.ownKeys()–返回对象的所有属性的数组(包括常规属性和符号属性)
- Object.getOwnPropertyDescriptors()–获取一个对象所有自身的属性和值,包括value、writable(可写)、enumerable(可枚举)、configurable(可配置)
小Tips:当然,我们也可以不用像上面一样把符号实例化的对象专门保存到变量中。但是如果不显式地保存这些属性的引用,就必须遍历对象所有的符号才能找到相应的属性键。
(4)常用内置符号
ECMAScript6引入了一批常用内置符号(well-known symbol),用于暴露语言内部的行为,开发者可以访问、重写或模拟这些行为。这些内置符号都以Symbol工厂函数字符串属性的形式存在。
在提到ECMAScript规范时,经常会引用符号在规范中的名称,前缀为@@。比如,@@iterator指的就是Symbol.iterator。
(5)Symbol.iterator
一个对象只要实现了Symbol.iterator属性就能够使用for…of和…(拓展运算符)。其中,Array
、TypedArray
、String
、Map
、Set
都已经默认实现了这个属性,所以他们就能够使用for…of和…了。
但Object并没有实现,所以Object则不能使用。其实for-of的原理是通过调用属性[Symbol.iterator]()这个函数,这个迭代器函数返回一个next函数,for循环会不断调用next。
知道了这个原理,我们就可以手动地在Object上实现Symbol.iterator属性,进而在Object上也能实现迭代器的功能,甚至再多做一些额外行为。
(6)Symbol.asyncIterator
既然说实现了Symbol.iterator的对象都能够使用常规的迭代器特性,那么实现了Symbol.asyncIterator的对象则可以异步地进行迭代。
不过Symbol.asyncIterator是ECMAScript2018定义的,因此只有版本非常新的浏览器支持它。不过,目前并没有默认设定了[Symbol.asyncIterator]属性的JavaScript内建的对象,如果需要这个功能同样可以手动实现它。
(7)Symbol.hasInstance
根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法决定一个构造器对象是否认可一个对象是他的实例。”。
在ES6中,instanceof操作符会使用Symbol.hasInstance函数来确定关系。
(8)Symbol.isConcatSpreadable
根据ECMAScript规范,这个符号作为一个属性表示“一个布尔值,如果是true,则意味着对象应该用Array.prototype.concat()打平其数组元素”。
用通俗的话解释。例如,对一个数组调用concat()时,如果是默认的array[Symbol.isConcatSpreadable]为true,则是常规地将两个数组拼接起来。如果其中一个数组的array[Symbol.isConcatSpreadable]为false,则这个数组将整体作为一个对象和另一个数组拼接起来。
(9)Symbol.match
根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法用正则表达式区匹配字符串。”
正则表达式RegExp的原型上默认有这个[Symbol.match]属性,在调用String.prototype.match()时会通过Symbol.match进行求值。重新定义Symbol.match就可以取代默认正则表达式求值的行为。
(10)Symbol.replace
根据ECMAScript规范,这个符号表示“一个正则表达式方法,该方法替换一个字符串匹配的子串”。
String.prototype.replace()方法会使用以replace为键的函数对正则表达式求值。
(11)Symbol.search
根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法返回字符串中匹配正则表达式的索引”。
String.prototype.search()会使用以Symbol.search为键的函数来对正则表达式求值。
(12)Symbol.species
根据ECMAScript规范,这个符号作为一个属性表示“一个函数值,该函数作为创建派生对象的构造函数”。
这个稍微有点不容易理解,这里举个小例子:
class MyArray extends Array {
// 覆盖 species 到父级的 Array 构造函数上
static get [Symbol.species]() { return Array; }
}
var a = new MyArray(1,2,3);
var mapped = a.map(x => x * x);
console.log(mapped instanceof MyArray); // false
console.log(mapped instanceof Array); // true
你可能想在扩展数组类MyArray上返回Array对象。例如,当使用例如map()这样的方法返回默认的构造函数时,你希望这些方法能够返回父级的Array对象,以取代MyArray对象。
(13)Symbol.split
根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法在匹配正则表达式的索引位置拆分字符串”。
String.prototype.split()方法会使用以Symbol.split为键的函数来对正则表达式求值。
(14)Symbol.toPrimitive
根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法将对象转换为相应的原始值。由ToPrimitive抽象操作使用”。很多内置操作(比如console.log打印)都会尝试强制将对象转化为原始值,包括字符串、数值和未指定的原始类型。
对于一个自定义对象实例,通过在这个实例的Symbol.toPrimitive属性上定义一个函数就可以改变默认行为。
举个例子:
let obj = {}
console.log(+obj)//NaN
obj[Symbol.toPrimitive] = (hint) =>{
//遇到需要转化为number类型时
if (hint === 'number') {
return 123;
}
return null;
}
console.log(+obj)//123
(15)Symbol.toStringTag
根据ECMAScript规范,这个符号作为一个属性表示“一个字符串,该字符串用于创建对象的默认字符串描述。由内置方法Object.prototype.toString()使用”。
举个例子:
class A {}
let a = new A()
console.log(a.toString())//[object Object]
a[Symbol.toStringTag] = 'A'
console.log(a.toString())//[object A]
(16)Symbol.unscopables
根据ECMAScript规范,这个符号作为一个属性表示“一个对象,该对象所有的以及继承的属性,都会从关联对象with环境绑定中排除”。设置这个符号并让其映射对应属性的键值为true,就可以阻止该属性出现在with环境绑定中。
不推荐使用with,因为性能差且难优化,因此也不推荐使用Symbol.unscopables。
3.4.8 Object类型
ECMAScript中的对象其实就是一组数据和功能的集合。
我们可以这样创建对象:
let obj = new Object()
如果不用传入参数,也可以不加括号:
let obj = new Object//语法允许,但不推荐
每一个Object实例都有如下的属性和方法:
- constructor:用于创建当前对象的函数。例如前面的Object()。
- hasOwnProperty(propertyName):判断当前对象实例上是否存在给定属性。
- isPrototypeOf(object):判断当前对象是否是另一个对象的原型。
- propertyIsEnumerable(propertyName):判断给定属性是否可以使用for-in枚举。
- toLocalString():返回对象的字符串表示,该字符串反应对象所在的本地化执行环境。
- toString():返回对象的字符串表示。
- valueOf():返回对象的字符串、数值或布尔值表示。通常与toString()的返回值相同。
3.5 操作符
ECMA-262描述了一组可用于操作数据值的操作符,包括数学操作符、位操作符、关系操作符、相等操作符。
3.5.1 一元操作符
(1)递增/递减操作符
这个大家应该很熟悉了,++
和--
,通过这两个操作符给数值自增或自减1。这里就产生了副作用。
副作用:在计算机科学中,函数副作用指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数或改变外部存储。
(2)一元加和减
一元加(+)和减(-)在ECMAScript中和高中数学中的用途一样,放在数值前表示正负。
对于非数值变量的加和减,则执行前面提到的Number()的转换规则。
3.5.2 位操作符
首先要承认,位操作符是我初学编程语言时最头疼的知识点了。主要是因为它的数理逻辑是基于二进制的,而并非我们熟悉的十进制,所以初学时会有一些盲区障碍。
位运算符用于数值的底层操作,所以性能上也是相对较好的。缺点是很不直观,但在某些特殊的业务场景下,能发挥“四两拨千斤”(用少量代码完成功能需求)的作用。
ECMAScript中所有数值都以IEEE 754 64位存储,但位操作符不直接应用到64位表示,而是先把值转化为32位整数,在进行位操作,之后再把结果转化为64位。因此,我们只需要考虑32位整数即可。
有符号整数使用前31位表示整数值,第32位(最左边的)表示数值的符号(0正1负),也称为符号位。正值以二进制形式存储,负值以二补数/补码的形式存储。
基本的二进制转化这里就不再过多阐述,这里说一下二补数的3个执行步骤:
- 绝对值原码:确定绝对值的二进制表示。(如-18,先确定18的二进制10010)
- 反码/补数:对所有位取反,即所有0变成1,1变成0。
- 补码/二补数:给反码的结果加1。
举个例子,来确定-18的补码运算过程:
0000 0000 0000 0000 0000 0000 0001 0010(绝对值源码)
1111 1111 1111 1111 1111 1111 1110 1101(反码/补数)
1111 1111 1111 1111 1111 1111 1110 1110(补码/二补数)
这里要注意,这里只是说在计算机中,负值以二补数的形式存储,但在我们日常的“十进制转二进制”时,-18则会转化为-10010。其实这个转化过程涉及到了二补数,只不过我们以更加符合逻辑的形式来表现出来。
在ECMAScript中的数值应用位操作符时,如果遇到特殊值NaN和Infinity值,在位操作符中都会被当作0处理。如果遇到非数值,则会用Number()进行转化成数值再应用位操作。
(1)按位非
按位非用波浪符表示~
,作用是返回数值的补数。
按位,含义是按照每一位,其实“按位非”可以这样解读:按照二进制的每一位,对他们做非运算。后面的按位与和按位或同理。
let num1 = 25//0000 0000 0000 0000 0000 0000 0001 1001
let num2 = ~25//1111 1111 1111 1111 1111 1111 1110 0110
console.log(num2)//-26
实际上,按位非的最终效果就是对数值取反并减1,但它对速度比直接对减号运算要快得多。
(2)按位与
按位与操作符用和号&
表示。表示将两个数的每一位对其,然后分别做与运算。
按位与相当于数学上的合取,在逻辑命题上则相当于,两个命题都为真则是真,其中一个命题为假则为假。
第一个数值位 | 第二个数值位 | 结果 |
---|---|---|
1 | 1 | 1 |
1 | 0 | 0 |
0 | 1 | 0 |
0 | 0 | 0 |
举个例子:
let result = 25 & 3
console.log(result)//1
25 = 0001 1001
3 = 0000 0011
---------------
r = 0000 0001 //转化为10进制的结果是1
(3)按位或
按位或操作符用管道符|
表示。表示将两个数的每一位对其,然后分别做或运算。
同理按位与,按位或相当于数学上的析取,在逻辑命题上则相当于,两个命题只要有一个为真则是真,都为假则为假。
第一个数值位 | 第二个数值位 | 结果 |
---|---|---|
1 | 1 | 1 |
1 | 0 | 1 |
0 | 1 | 1 |
0 | 0 | 0 |
(4)按位异或
按位异或用脱字符^
表示,对每一位做异或运算。
这个按位异或像是高中物理中的,磁场中同性排斥,异性相吸的属性。
第一个数值位 | 第二个数值位 | 结果 |
---|---|---|
1 | 1 | 0 |
1 | 0 | 1 |
0 | 1 | 1 |
0 | 0 | 0 |
(5)左移
左移操作符用两个小于号<<
表示,会按照指定位数将数值的所有位向左移动。
举个例子:
let oldValue = 2//二进制表示为0010
let newValue = 2 << 1 //左移1位得0100
console.log(newValue)//十进制的4
注意,左移会以0填充多出的空位,并且会保留符号位不变。所以,-2左移5位是-64,而不是64。
其实左移就相当于我们在十进制数学中的“乘十”的操作,对于二进制的2<<1 === 4
,就可以当作十进制的2e1 === 20
(6)有符号右移
有符号右移用>>
表示,相当于左移的逆运算,空位仍会被补0,溢出的位则会被忽略。
2 >> 1 //1,即0010➡️0001
3 >> 1 //1,即0011➡️0001
(7)无符号右移
无符号右移用>>>
表示,会将数值的32位都向右移动。对于正值来说和>>
结果相同,但对于负值来说,结果会差很多。
举个例子:
//等于二进制1111 1111 1111 1111 1111 1111 1100 0000
let oldValue = -64
//等于二进制0000 0111 1111 1111 1111 1111 1111 1110
let newValue = oldValue >>> 5
对于-64的无符号右移5位后,无符号右移会把负值的二进制表示当作正值来处理,也就是4 294 967 232。把这个值右移5位,结果是则是134 217 726。
3.5.3 布尔操作符
布尔操作符一共有三个:逻辑非、逻辑与、逻辑或。
这个“逻辑X“和”按位X“稍微长得有点儿像,初学者注意不要混淆了。
(1)逻辑非
逻辑非由叹号!
表示,它会首先将操作数转化为布尔值,再对其取反。
相关布尔值的转化已在前面提到过。
(2)逻辑与
逻辑与用两个和号&&
表示。它和按位与类似,都是做与操作。
第一个操作数 | 第二个操作数 | 结果 |
---|---|---|
true | true | true |
true | false | false |
false | true | false |
false | false | false |
要注意的是,逻辑与操作符可用于任何类型的操作数,不限于布尔值,如果操作的不是布尔值,则遵循一定规则,我通常这么记忆:
- 如果第一个操作数是false,直接短路返回false,不执行第二个操作符。
- 依次判断第一个和第二个操作数,如果是NaN、null或undefined,则返回NaN、null或undefined。
- 如果第一个操作数是对象,则返回第二个操作数。
(3)逻辑或
逻辑或由两个管道符||
表示。它和按位或类似,都是做或操作。
第一个操作数 | 第二个操作数 | 结果 |
---|---|---|
true | true | true |
true | false | true |
false | true | true |
false | false | false |
同样,如果其中一个操作数不是布尔值,那么逻辑或操作符也不一定返回布尔值。
- 如果第一个操作数是true,则直接短路返回第一个操作数,不执行第二个操作数。
- 如果两个操作数都是null、NaN或undefined,则返回第二个操作数。
- 如果第一个操作数是对象,则返回第一个操作数。
我们常用逻辑或的短路行为来避免给变量赋值null和undefined。
举个例子:
let myObject = preferredObject || backupObject
在这个例子中,myObject会被赋予两个值中的一个。preferredObject是首选值,backupObject是备选值。如果preferredObject为null,则backupObject作为第二顺位为变量赋值。如果需要加上第三、第四顺位,则继续在backupObject后用逻辑或进行补充。
3.5.4 乘性操作符
ECMAScript定义了三个乘性操作符:乘法、除法、取模。同样的,如果遇到非数值运算,该操作数会在后台使用Number()转型函数转换为数值。
(1)乘法操作符
乘法操作符用*
表示。
这里聊聊乘法操作符对特殊值的处理:
- 如果任一操作数为NaN,则返回NaN。
- Infinity乘0,返回NaN。
- Infinity乘非0数值(包括Infinity),根据第二个操作数符号返回Infinity或-Infinity。
(2)除法操作符
除法操作符用/
表示。
除法操作符对特殊值的处理:
- 如果任一操作数为NaN,则返回NaN。
- Infinity除以0,0除以0,均返回NaN。
- 非0有限值除以0,根据第一个操作数返回Infinity和-Infinity。
(3)取模操作符
取模(余数)操作符由一个百分比符号%
表示。
取模操作符对特殊值的处理:
- 无限值%有限值,返回NaN。
- 有限值%0,返回NaN。
- Infinity%Infinity,返回NaN。
- 有限值%无限值,返回有限值。
- 0%非0,返回0。
3.5.5 指数操作符
ECMAScript新增了指数操作符,用两个星号表示**
,用来代替Math.pow()。
let num1 = Math.pow(2, 3) //8
let num2 = 2 ** 3 //8
不仅如此,指数操作符也有自己的指数赋值操作符**=
。
let num = 2
num **= 3
console.log(num)//8
3.5.6 加性操作符
加性操作符,即加法操作符和减法操作符。
(1)加法操作符
加法操作符+
用于求两个数的和。
加法操作符对特殊值的处理:
- 任意操作数为NaN,返回NaN。
- Infinity+Infinity,返回Infinity。
- (-Infinity) + (-Infinity),返回-Infinity。
- -Infinity + Infinity,返回NaN。
- (+0) + (+0),返回+0
- (+0) + (-0),返回+0
- (-0) + (-0),返回-0
- 如果有一个操作数是字符串,则应用以下规则:
- 两个操作数都是字符串,则字符串按顺序拼接。
- 只有一个操作数是字符串,则将另一个操作数转换为字符串再拼接。
- 含字符串的加法算式中含有对象、数值、布尔值,则调用它们的toString()获取字符串,再应用前面的字符串规则。对于undefined和null,则利用String()获取
“undefined”
和"null"
。
(2)减法操作符
减法操作符-
用于求两个数差。
减法操作对特殊值的处理。
- 任意操作数为NaN,返回NaN。
- Infinity-Infinity,返回NaN。
- (-Infinity) - (-Infinity),返回-NaN。
- Infinity - (-Infinity),返回Infinity。
- (-Infinity) - Infinity,返回-Infinity。
- (+0) - (+0),返回+0
- (+0) - (-0),返回-0
- (-0) - (-0),返回+0
- 如果任一操作数是字符串、布尔值、null或undefined,现在后台使用Number()转化为数值,再进行前面的数学运算。
- 如果任一操作数是对象,调用valueOf()获取他的数值,如果没有valueOf()方法,则调用toString()方法,将得到的字符串转化为数值。
3.5.7 关系操作符
关系操作符执行比较两个值的操作,包括大于>
、小于<
、小于等于<=
和大于等于>=
,返回布尔值。
关系操作符对特殊值的处理:
- 如果操作数都是数值,则执行数值比较。
- 如果操作数都是字符串,则按照字符编码进行比较。
- 如果任一操作数是数值,则将另一个操作数转换为数值,执行数值比较。
- 如果任一操作数是对象,则依次调用valueOf()和toString()方法,将取得的结果应用前面的规则进行比较。
- 如果任一操作数是布尔值,则将其转化为数值进行比较。
- 含有NaN的比较式,均返回false。
注意,对于字符串的比较,不要单纯地以为按照字母顺序来比较。要考虑大小写字母的情况,大写字母的编码都小于小写字母的编码。
3.5.8 相等操作符
相等操作符是编程中最重要的操作之一。不过在ECMAScript中,相等操作符有两套:等于和不等于、全等和不全等。
(1)等于和不等于
等于操作符==
和不等于操作符!==
,都会在比较前进行强制类型转换,再确定操作数是否相等。
比如字符串"55"和数值55使用==
进行比较会返回true,因为会字符串“55”会被转换成数值55再比较。其他的转换规则关系操作符类似,不过要注意null==undefined会返回true。
(2)全等和不全等
全等操作符===
和不全等操作符!==
,和等于和不等于操作符类似,不过他们在比较多时候不进行类型转换,即“比较数据类型,然后还比较值”。
要注意,null===undefined会返回false,因为它们是不同的数据类型。
由于
==
和!=
会出现类型转换的问题,因此推荐使用===
和!==
来比较,这样有助于在代码中保持数据类型的完整性。
3.5.9 条件运算符
条件运算符,又称为三元运算符,是一种条件判断的简写:
let minValue = value1 < value2 ? value1 : value2
3.5.10 赋值运算符
简单赋值就是常规的等于号=
,也有一些复合赋值操作符*=
,/=
,%=
,+=
,-=
,<<=
,>>=
,>>>=
,**=
。这些操作符仅是简写语法,并不会提升性能。
3.5.11 逗号操作符
逗号操作符用来在一条语句中执行多个操作:
let num1 = 1, num2 = 2, num3 = 3
还有一种不太常见的行为:赋值时使用逗号操作符分隔值,最终会返回最后一个值。
let num = (5, 4, 1, 3, 0)//num值为0
3.6 语句
3.6.1 if语句
if(condition)
statement1
else
statement2
条件(condition)可以是任意表达式,ECMAScript会自动调用Boolean()来将表达式转化为布尔值。
当然,为了最佳实践,即使只有一行代码,statement也要加上大括号{}
。不仅可以让我们编码时思路更加清晰,也可以方便地利用IDE的代码折叠功能。
3.6.2 do-while语句
do{
statement
}while(expression)
do-while语句是一种后测试循环语句,常用于循环代码至少要执行1次的场景。
3.6.3 while语句
while(expression){
statement
}
while语句是先测试语句,常用于代码循环至少要执行0次的场景。
3.6.4 for语句
for(initialization; expression ;post-loop-expression){
statement
}
for语句也是先测试语句,只不过增加了进入循环前的初始化代码,以及循环执行后要执行的表达式。
3.6.5 for-in语句
for(property in expression){
statement
}
for-in语句是一种严格的迭代语句,用于枚举对象中的非符号(Symbol)属性。
let s = Symbol()
let obj = {
prop1:'prop1',
prop2:'prop2',
[s]:'symbol'
}
for(const p in obj){
console.log('利用for-in:',p)//只输出prop1和prop2
}
console.log(obj)//{ prop1: 'prop1', prop2: 'prop2', [Symbol()]: 'symbol' }
ECMAScript中对象的属性是无序的,所以利用for-in语句不能保证返回对象属性的顺序。换句话说,所有被枚举的属性都会被返回一次,返回的顺序可能会因浏览器而异。
如果for-in要迭代的变量是null或undefined,则不执行循环体。
3.6.6 for-of语句
for-of是一种严格的迭代语句。用于遍历可迭代对象的元素。
与for-in的语法类似:
for(property of expression){
statement
}
举个例子:
for(const el of [2,4,6,8]){
console.log(el)//分别输出2、4、6、8
}
for-of循环会按照可迭代对象的next()方法产生值的顺序迭代元素。如果尝试迭代的元素不支持迭代,则for-of会抛出错误。
ES2018对for-of语句进行了拓展,增加了for-await-of循环,以支持生成期约(promise)的异步可迭代对象。
3.6.7 标签语句
label : statement
标签语句用于给语句添加标签,可以在后面通过break或continue语句引用。标签语句的经典场景是嵌套循环,例如我们可以在内存循环里一个break就能跳出外层循环。
3.6.8 break和continue语句
break语句用于立即退出当前整个循环语句,continue会退出当前的循环代码块,然后继续执行循环体。
3.6.9 with语句
with语句的用途是将代码作用域设置为指定的对象。
with(expression)
statement
举个例子:
let obj = {
log(){
console.log('log')
}
}
with (obj) {
log()//输出log,相当于obj.log()
}
在with语句内部,每个变量首先都会被看作一个局部变量,如果没有找到,则会找obj上是否有同名变量。
这其实就相当于在全局作用域中,我们将window放入到expression的位置,然后在全局中可以直接调用window的属性和方法,而不用再通过window对象调用。
严格模式不允许使用with。
由于,with语句难以调试,通常不建议再产品代码中使用。
3.6.10 switch语句
switch(expression){
case value1: statement;break;
case value2: statement;break;
...
default:statement;
}
swtich语句就是相当于if……else语句的连用语法的改版,不过要记得在每一个case(分支)后加上break语句表示跳出该swtich。
其实在value1、value2……的位置,我们也可以使用表达式来替代,case 1<2
则后面的语句一定会执行。
switch (true){
case 1<2:console.log(123);break;
case 2<3:console.log(345);break;
default:console.log('defult');
}
//123
switch在比较每一个case时会使用全等操作符,因此不会强制转化数据类型,所以字符串数值和普通数值的比较将返回false。
3.7 函数
function functionName(arg0, arg1, ……, argN){
statements
}
函数应该就是老朋友了,如果我们不使用return,那函数将默认返回undefined。也就直接相当于return;
。
函数的最佳实践时要么返回值、要么不返回。只在某个条件下返回值会导致麻烦,尤其是调试时。
严格模式对函数有一些限制:
- 函数名和参数名不能叫eval、argument。
- 两个函数的参数不能叫同一个名称。
小结
本章节是JavaScript的核心语言特性,其实也是大多数其他编程语言具有的“共性”逻辑。从声明指定数据类型的变量,到各种操作符的使用,到表达编程思维的语句和函数,其实还是为了表达“人的思维”。理解这部分内容,是初学者入门JavaScript这门语言的关键。
这个部分对于大部分的前端从业者来说,基础ES5语法部分其实已经了如指掌了,但对于ES6或更新的语法,相信会获得更新的体会。尤其是关于Symbol在ECMAScript中的意义、新增的条件语句(for-of)、简化的语法操作(如**运算符等)等等,能对我们未来编程产生更大的辅助作用。
与时俱进,是对自己负责。