Js 简介什么是 Js?
Js 最初被创建的目的是“使网页更生动”。
Js 写出来的程序被称为 脚本,Js 是一门脚本语言。
- 被直接写在网页的 HTML 中,在页面加载的时候自动执行
- 脚本被以纯文本的形式提供和执行,不需要特殊的准备或编译即可运行(JIN compiler)
Js 不仅可以在浏览器中执行,也可以在服务端执行,本质上是它可以在任意搭载了Js 引擎的设备中执行。
浏览器中嵌入了 Js 引擎,有时也称作“JavaScript 虚拟机”,不同的引擎有不同的“代号”,例如:
- V8 —— Chrome、Opera 和 Edge 中的 Js 引擎。
- SpiderMonkey —— Firefox 中的 Js 引擎。
- Chakra —— IE
- JavaScriptCore、Nitro 、 SquirrelFish —— Safari
eg:如果 “V8 支持某个功能” ,那么我们可以认为这个功能大概能在 Chrome、Opera 和 Edge 中正常运行。
引擎是如何工作的?
引擎很复杂,但是基本原理很简单。
- 引擎(如果是浏览器,则引擎被嵌入在其中)读取(“解析”)脚本。
- 然后,引擎将脚本转化(“编译”)为机器语言。
- 然后,机器代码快速地执行。
引擎会对流程中的每个阶段都进行优化。它甚至可以在编译的脚本运行时监视它,分析流经该脚本的数据,并根据获得的信息进一步优化机器代码。
浏览器中的 Js能做什么?
现代的 Js 是一种“安全的”编程语言。它不提供对内存或 CPU 的底层访问,因为它最初是为浏览器创建的,不需要这些功能。
Js 的能力很大程度上取决于它运行的环境。例如,Node.js 支持允许 Js 读取/写入任意文件,执行网络请求等。
浏览器中的 Js 可以做下面这些事:
- 在网页中添加新的 HTML,修改网页已有内容和网页的样式。
- 响应用户的行为,响应鼠标的点击,按键的按动。
- 向远程服务器发送网络请求,下载和上传文件(所谓的 AJAX 和 COMET 技术)。
- 获取或设置 cookie,向访问者提出问题或发送消息。
- 记住客户端的数据(“本地存储”)。
不能做什么?
为了用户的(信息)安全,在浏览器中的 Js 的能力是受限的。
目的是防止恶意网页获取用户私人信息或损害用户数据。
此类限制的例子包括:
网页中的 Js 不能读、写、复制和执行硬盘上的任意文件。它没有直接访问操作系统的功能。
现代浏览器允许 Js 做一些文件相关的操作,但是这个操作是受到限制的。
仅当用户做出特定的行为,Js 才能操作这个文件。eg:用户把文件“拖放”到浏览器中,或者通过
标签选择了文件。
有很多与相机/麦克风和其它设备进行交互的方式,但是这些都需要获得用户的明确许可。
不同的标签页/窗口之间通常互不了解。
有时候,也会有一些联系,例如一个标签页通过 Js 打开的另外一个标签页。
但即使在这种情况下,如果两个标签页打开的不是同一个网站(域名、协议或者端口任一不相同的网站),它们都不能相互通信。这就是所谓的“同源策略”。
为了解决“同源策略”问题,两个标签页必须都包含一些处理这个问题的特定的 Js 代码,并均允许数据交换。
Js 可以轻松地通过互联网与当前页面所在的服务器进行通信。但是从其他网站/域的服务器中接收数据的能力被削弱了。尽管可以,但是需要来自远程服务器的明确协议(在 HTTP header 中)。这也是为了用户的信息安全。
数据类型
在 Js 中有 8 种基本的数据类型(7 种原始类型和 1 种引用类型)
Number 类型
代表整数和浮点数,可以有很多操作,eg:乘法 *
、除法 /
、加法 +
、减法 -
等等。
除了常规的数字,还包括所谓的“特殊数值”:Infinity
、-Infinity
和 NaN
。
科学计数法
- 将
"e"
和 0 的数量附加到数字后。就像:123e6
与123
后面接 6 个 0 相同。 "e"
后面的负数将使数字除以 1 后面接着给定数量的零的数字。例如123e-6
表示0.000123
(123
的百万分之一)。
多种进制
可以直接在十六进制(
0x
),八进制(0o
or00
)和二进制(0b
or0B
)系统中写入数字。使用
Number()
方法将含有对应前缀的字符串数值转为十进制Number('0b111') // 7Number('0o10') // 8
parseInt(str, base)
将字符串str
解析为在给定的base
数字系统中的整数,2 ≤ base ≤ 36
。num.toString(base)
将数字转换为在给定的base
数字系统中的字符串。
常规数字检测全局方法
isNaN(value)
—— 将其参数转换为数字,然后检测它是否为NaN
isFinite(value)
—— 将其参数转换为数字,如果它是常规数字,则返回true
(NaN/Infinity/-Infinity
返回false)
定义在Number上的方法 (ES6)
Number.isNaN()
——检查一个值是否为NaN
。如果参数类型不是NaN
,Number.isNaN
一律返回false
Number.isFinite()
—— 检查一个数值是否为有限的。如果参数类型不是数值,Number.isFinite
一律返回false
。
区别
传统的全局方法isFinite()
和isNaN()
先调用Number()
将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,Number.isFinite()
对于非数值一律返回false
, Number.isNaN()
只有对于NaN
才返回true
,非NaN
一律返回false
。
isFinite(25) // trueisFinite("25") // trueNumber.isFinite(25) // trueNumber.isFinite("25") // falseisNaN(NaN) // trueisNaN("NaN") // trueNumber.isNaN(NaN) // trueNumber.isNaN("NaN") // falseNumber.isNaN(1) // false
不规则字符串转换为数字
任务:将 12pt
和 100px
之类的值转换为数字
全局方法
- 使用
parseInt/parseFloat
进行“软”转换,它从字符串中读取数字,然后返回在发生 error 前可以读取到的值。
定义在Number上的方法 (ES6)
- ES6 将全局方法
parseInt()
和parseFloat()
,移植到Number
对象上面,行为完全保持不变。这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。
Math 对象的扩展
使用
Math.floor
,Math.ceil
,Math.trunc
,Math.round
或num.toFixed(precision)
进行舍入。其中Math.trunc() —— 用于去除一个数的小数部分,返回整数部分(内部使用Number
方法将其先转为数值)Math.sign() —— 用来判断一个数到底是正数、负数、还是零。对于非数值,会先将其转换为数值。
- 参数为正数,返回
+1
; - 参数为负数,返回
-1
; - 参数为 0,返回
0
; - 参数为-0,返回
-0
; - 其他值,返回
NaN
。
- 参数为正数,返回
Math.cbrt() —— 计算一个数的立方根
Math.clz32() —— 将参数转为 32 位无符号整数的形式,然后返回这个 32 位值里面有多少个前导 0
Math.imul() —— 返回两个数以 32 位带符号整数形式相乘的结果,返回的也是一个 32 位的带符号整数。
Math.fround() —— 返回一个数的32位单精度浮点数形式
Math.hypot() —— 返回所有参数的平方和的平方根
使用两个点来调用一个方法
请注意 123456..toString(36)
中的两个点不是打错了。如果我们想直接在一个数字上调用一个方法,比如上面例子中的 toString
,那么我们需要在它后面放置两个点 ..
。
如果我们放置一个点:123456.toString(36)
,那么就会出现一个 error,因为 Js 语法隐含了第一个点之后的部分为小数部分。如果我们再放一个点,那么 JavaScript 就知道小数部分为空,现在使用该方法。
也可以写成 (123456).toString(36)
。
如果是小数:可以直接写为0.13.toFixed(1)
数值分隔符
ES2021,允许 Js 的数值(所有进制)使用下划线(_
)作为分隔符,这个数值分隔符没有指定间隔的位数。
使用注意点:
- 不能放在数值的最前面或最后面。
- 不能两个或两个以上的分隔符连在一起。
- 小数点的前后不能有分隔符。
- 科学计数法里面,表示指数的
e
或E
前后不能有分隔符。 - 分隔符不能紧跟着进制的前缀
0b
、0B
、0o
、0O
、0x
、0X
(eg:0_b1100,0b_0100)
下面三个将字符串转成数值的函数,不支持数值分隔符:
- Number()
- parseInt()
- parseFloat()
BigInt 类型
number 类型无法安全地表示大于 (253-1),或小于 – (253-1)的整数。
更准确的说:
“number” 类型可以存储更大的整数,但超出安全整数范围 ±(253-1)会出现精度问题,因为并非所有数字都适合固定的 64 位存储。因此,可能存储的是“近似值”。
// 尾部的 "n" 表示这是一个 BigInt 类型const bigInt = 1234567890123456789012345678901234567890n;
String 类型
三种包含字符串的方式:
双引号:”Hello”
单引号:’Hello’
反引号:`Hello`
反引号是 功能扩展 引号,称为模板字符串。
它允许我们通过将变量和表达式包装在
${…}
中,来将它们嵌入到字符串中,并且可以在里面直接换行。
Js 中的字符串使用的是 UTF-16 编码。
Js 中字符串不可以被改变
length
属性表示字符串长度可以使用像
\n
这样的特殊字符或通过使用\u...
来操作它们的 Unicode 进行字符插入。其中
\uxxxx
是字符的Unicode表示法,这种表示法只限于码点在\u0000
~\uFFFF
之间的字符。超出这个范围的字符,必须用两个双字节的形式表示。ES6 对这一点做出了改进,只要将码点放入大括号,就能正确解读该字符。"\uD842\uDFB7" // "𠮷" "\u20BB7" // " 7""\u{20BB7}"// "𠮷" /*如果直接在`\u`后面跟上超过`0xFFFF`的数值(比如`\u20BB7`),Js 会理解成`\u20BB+7`。由于`\u20BB`是一个不可打印字符,所以只会显示一个空格,后面跟着一个`7`。*/
获取字符时,使用
[]
orcharAt
,它们之间的唯一区别是,如果没有找到字符,[]
返回undefined
,而charAt
返回一个空字符串获取子字符串,使用
slice
或substring
。方法 选择方式 负值参数 slice(start, end)
从 start
到end
(不含end
),start可以比end大允许 substring(start, end)
从 start
到end
(不含end
),start可以比end大负值被视为 0
substr(start, length)
从 start
开始获取长为length
的字符串允许 start
为负数let str = "stringify";// 这些对于 substring 是相同的alert( str.substring(6, 2) ); // "ring"// ……但对 slice 是不同的:alert( str.slice(6, 2) ); // ""(空字符串)
字符串的大/小写转换,使用:
toLowerCase/toUpperCase
。查找子字符串时,使用
indexOf
或includes/startsWith/endsWith
(ES6)进行简单检查。这里检查是否找到子字符串时使用的一个技巧是
~
运算符。它将数字转换为 32-bit 整数(如果存在小数部分,则删除小数部分),然后对其二进制表示形式中的所有位均取反。实际上,这意味着一件很简单的事儿:对于 32-bit 整数,
~n
等于-(n+1)
。原因:在补码中,符号位不变,数值位 取反加1 得 -n ,表示为 -n = 取反 + 1 ,只取反为 ~n = -n – 1 = -(n+1)
人们用它来简写
indexOf
检查:let str = "Widget";// 找到:返回值>0,不为-1if (~str.indexOf("Widget")) {alert( 'Found it!' ); // 正常运行}
根据语言比较字符串时使用
localeCompare
,否则将按字符代码进行比较。ES6的字符串的遍历器接口(for…of…)可以识别大于
0xFFFF
的码点,传统的for
循环无法识别这样的码点。str.trim()
—— 删除字符串前后的空格 (“trims”)。str.repeat(n)
—— 重复字符串n
次。
ES6新增字符串方法
String.fromCodePoint() 与 实例方法:codePointAt()
对比:
String.fromCharCode()
—— 从Unicode码点返回对应字符串,不能识别大于0xFFFF的字符(charCodeAt)String.fromCodePoint()
—— 可以识别大于0xFFFF
的字符 (注意,fromCodePoint
方法定义在String
对象上,而codePointAt
方法定义在字符串的实例对象上。)
// 大于0xFFFF,舍去最高位‘2’String.fromCharCode(0x20BB7)// "ஷ"String.fromCodePoint(0x20BB7)// "𠮷"// 有多个参数,则合并成一个字符串返回。String.fromCodePoint(0x78, 0x1f680, 0x79) === 'x\uD83D\uDE80y'// true// 解决 字符a在字符串s的正确位置序号应该是 1,但是必须向codePointAt()方法传入 2。let s = '𠮷a';for (let ch of s) { console.log(ch.codePointAt(0).toString(16));}let arr = [...'𠮷a']; // arr.length === 2arr.forEach( ch => console.log(ch.codePointAt(0).toString(16)));// 20bb7// 61
String.raw()
可以作为处理模板字符串的基本方法,它会将所有变量替换,而且对斜杠进行转义,方便下一步作为字符串来使用。
String.raw`Hi\\n` === "Hi\\\\n" // true// 如果写成正常函数的形式,它的第一个参数,应该是一个具有raw属性的对象,且raw属性的值应该是一个数组,对应模板字符串解析后的值。// `foo${1 + 2}bar`// 等同于String.raw({ raw: ['foo', 'bar'] }, 1 + 2) // "foo3bar"
实例方法:normalize()
ES6 提供字符串实例的
normalize()
方法,用来将字符的不同表示方法统一为同样的形式,这称为 Unicode 正规化。不过,
normalize
方法目前不能识别三个或三个以上字符的合成。这种情况下,还是只能使用正则表达式,通过 Unicode 编号区间判断。实例方法:repeat()
repeat
方法返回一个新字符串,表示将原字符串重复n
次。// 参数如果是小数,会被取整。'na'.repeat(2.9) // "nana"// 参数是负数或者Infinity,会报错。'na'.repeat(Infinity) // RangeError'na'.repeat(-1) // RangeError// 参数是 0 到-1 之间的小数,则等同于 0'na'.repeat(-0.9) // ""// 参数NaN等同于 0。'na'.repeat(NaN) // ""// 参数是字符串,则会先转换成数字。'na'.repeat('na') // ""'na'.repeat('3') // "nanana"
实例方法:padStart(),padEnd()
ES2017 引入了字符串补全长度的功能。
如果某个字符串不够指定长度,会在头部或尾部补全。
padStart()
用于头部补全,padEnd()
用于尾部补全。padStart()
和padEnd()
一共接受两个参数,第一个参数是字符串补全生效的最大长度,第二个参数是用来补全的字符串(省略则默认填充空格)。// 如果原字符串的长度,等于或大于最大长度,则字符串补全不生效,返回原字符串。'xxx'.padStart(2, 'ab') // 'xxx'// 如果用来补全的字符串与原字符串,两者的长度之和超过了最大长度,则会截去超出位数的补全字符串。'abc'.padStart(10, '0123456789')// '0123456abc'
常见用途:
- 为数值补全指定位数
- 提示字符串格式
实例方法:trimStart(),trimEnd()
ES2019 新增,它们的行为与
trim()
一致。trimStart()
消除字符串头部的空格,trimEnd()
消除尾部的空格。它们返回的都是新字符串,不会修改原始字符串。浏览器还部署了额外的两个方法,
trimLeft()
是trimStart()
的别名,trimRight()
是trimEnd()
的别名。实例方法:matchAll() —— 返回一个正则表式在当前字符串的所有匹配
实例方法:at() ——
at()
方法接受一个整数作为参数,返回参数指定位置的字符,支持负索引(即倒数的位置)。实例方法:replaceAll()
如果
searchValue
是一个不带有g
修饰符的正则表达式(只写为字符串,默认全局替换),replaceAll()
会报错。这一点跟replace()
(不写全局修饰符,只替换找到的第一个)不同。// 不报错'aabbcc'.replace(/b/, '_')// 报错'aabbcc'.replaceAll(/b/, '_')
上面例子中,
/b/
不带有g
修饰符,会导致replaceAll()
报错。replaceAll()
的第二个参数replacement
是一个字符串,表示替换的文本,其中可以使用一些特殊字符串。
Boolean 类型(逻辑类型)
仅包含两个值:true
和 false
。
原始类型的方法
除
null
和undefined
以外的原始类型都提供了许多有用的方法。从形式上讲,这些方法通过临时对象工作,但 Js 引擎可以很好地调整,以在内部对其进行优化,因此调用它们并不需要太高的成本。
let str = "Hello";alert( str.toUpperCase() ); // HELLO
以下是
str.toUpperCase()
中实际发生的情况:- 字符串
str
是一个原始值。因此,在访问其属性时,会创建一个包含字符串字面值的特殊对象,并且具有可用的方法,例如toUpperCase()
。 - 该方法运行并返回一个新的字符串(由
alert
显示)。 - 特殊对象被销毁,只留下原始值
str
。
重要例子
let str = "Hello";str.test = 5; // (*)alert(str.test);
根据你是否开启了严格模式
use strict
,会得到如下结果:undefined
(非严格模式)- 报错(严格模式)。
为什么?让我们看看在
(*)
那一行到底发生了什么:- 当访问
str
的属性时,一个“对象包装器”被创建了。 - 在严格模式下,向其写入内容会报错。
- 否则,将继续执行带有属性的操作,该对象将获得
test
属性,但是此后,“对象包装器”将消失(对应上诉的特殊对象被销毁),因此在最后一行,str
并没有该属性的踪迹。
这个例子清楚地表明,原始类型不是对象。
它们不能存储额外的数据。
let str = new String("hello");str.name = "String"alert(str.name);
但是这样在严格or非严格模式下可以,原因:真正创建了一个对象
- 字符串
构造器 String/Number/Boolean
仅供内部使用
像 Java 这样的一些语言允许我们使用 new Number(1)
或 new Boolean(false)
等语法,明确地为原始类型创建“对象包装器”。在 Js 中,由于历史原因,这也是可以的,但极其 不推荐。因为这样会出问题。
例如:
alert( typeof 0 ); // "number"alert( typeof new Number(0) ); // "object"!
对象在 if
中始终为真,所以此处的 alert 将显示:
let zero = new Number(0);if (zero) { // zero 为 true,因为它是一个对象 alert( "zero is truthy?!?" );}
另一方面,调用不带关键字 new
的 String/Number/Boolean
函数是可以的且有效的。它们不是用来创建对象包装器,而是将一个值转换为相应的类型:转成字符串、数字或布尔值(原始类型)。
例如,下面完全是有效的:
let num = Number("123"); // 将字符串转成数字
null 值
特殊的 null
值不属于上述任何一种类型,它构成了一个独立的类型,只包含 null
值。
相比较于其他编程语言,Js 中的 null
不是一个 “对不存在的 object
的引用” 或者 “null 指针”。
Js 中的 null
仅仅是一个代表“无”、“空”或“值未知”的特殊值。
typeof null === 'object' 为 Js 的一个遗留错误。
undefined 值
undefined
的含义是未被赋值。
如果一个变量已被声明,但未被赋值,那么它的值就是 undefined
。
Object 类型和 Symbol 类型
object
类型是一个特殊的类型。其他所有的数据类型都被称为“原始类型”,因为它们的值只包含一个单独的内容(字符串、数字或其他)。相反,object
则用于储存数据集合和更复杂的实体,它被称为“引用类型”。
symbol
类型用于创建对象的唯一标识符。
typeof 运算符
typeof
运算符返回参数的类型。
当我们想要分别处理不同类型值的时候,或者想快速进行数据类型检验时,非常有用。
对 typeof x
的调用会以字符串的形式返回数据类型。
typeof null
的结果为"object"
。这是官方承认的typeof
的错误,这个问题来自于 Js 语言的早期阶段,并为了兼容性而保留了下来。null
绝对不是一个object
。null
有自己的类型,它是一个特殊值。typeof
的行为在这里是错误的。typeof alert
的结果是"function"
,因为alert
在 Js 语言中是一个函数。在 Js 语言中没有一个特别的 “function” 类型。函数隶属于object
类型。但是typeof
会对函数区分对待,并返回"function"
。这也是来自于 Js 语言早期的问题。
typeof(x)
语法你可能还会遇到另一种语法:
typeof(x)
。它与typeof x
相同。简单点说:
typeof
是一个操作符,不是一个函数。这里的括号不是typeof
的一部分。它是数学运算分组的括号。通常,这样的括号里包含的是一个数学表达式,例如
(2 + 2)
,但这里它只包含一个参数(x)
。从语法上讲,它们允许在typeof
运算符和其参数之间不打空格。
与浏览器的交互
与用户交互的 3 个浏览器的特定函数:
alert
显示信息。
prompt
显示信息要求用户输入文本。点击确定返回文本,点击取消或按下 Esc 键返回
null
。confirm
显示信息等待用户点击确定或取消。点击确定返回
true
,点击取消或按下 Esc 键返回false
。
这些方法都是模态的:它们暂停脚本的执行,并且不允许用户与该页面的其余部分进行交互,直到窗口被解除。
上述所有方法共有两个限制:
- 模态窗口的确切位置由浏览器决定。通常在页面中心。
- 窗口的确切外观也取决于浏览器。我们不能修改它。
这就是简单的代价。还有其他一些方式可以显示更漂亮的窗口,并与用户进行更丰富的交互。
类型转换
有三种常用的类型转换:
字符串转换 —— 转换发生在输出内容的时候,也可以通过 String(value)
进行显式转换。
数字型转换 —— 转换发生在进行算术操作时,也可以通过 Number(value)
进行显式转换。
数字型转换遵循以下规则:
值 | 变成 |
---|---|
undefined | NaN |
null | 0 |
true / false | 1 / 0 |
string | 字符串两端的空白字符(空格、换行符 \n 、制表符 \t 等)会被忽略。空字符串变成 0 。转换出错则输出 NaN 。 |
布尔型转换 —— 转换发生在进行逻辑操作时,也可以通过 Boolean(value)
进行显式转换。
布尔型转换遵循以下规则:
值 | 变成 |
---|---|
0 , null , undefined , NaN , "" | false |
其他值 | true |
上述的大多数规则都容易理解。通常会犯错误的例子有以下几个:
对
undefined
进行数字型转换时,输出结果为NaN
,而非0
。对
"0"
和只有空格的字符串(比如:" "
)进行布尔型转换时,输出结果为true
。对于什么时候时,”+”是字符串的拼接还是加法:
- 若第一个操作数为字符串,即为字符串的拼接
- 若第一个操作数不是字符串,如为 true 这样的boolean型,即为加法
数字型转化:一元运算符 +
加号 +
有两种形式。
一种是二元运算符,另一种是一元运算符。
一元运算符加号 +
应用于单个值,对数字没有任何作用,但如果运算元不是数字,加号 +
则会将其转化为数字。它的效果和 Number(...)
相同,但是更加简短。
例如:
// 对数字无效let y = -2;alert( +y ); // -2// 转化非数字alert( +true ); // 1alert( +"" ); // 0
值的比较奇怪的结果:null vs 0
通过比较 null
和 0 可得:
alert( null > 0 ); // (1) falsealert( null == 0 ); // (2) falsealert( null >= 0 ); // (3) true
上面的结果完全打破了你对数学的认识。在最后一行代码显示 “null
大于等于 0 为 true ” 的情况下,前两行代码中一定会有一个是正确的,然而事实表明它们的结果都是 false。
为什么会出现这种反常结果,这是因为相等性检查 ==
和普通比较符 > = <=
的代码逻辑是相互独立的。
进行值的比较时,null
会被转化为数字,因此它被转化为了 0
。
这就导致了(3)中 null >= 0
返回值是 true,(1)中 null > 0
返回值是 false。
undefined
和 null
在相等性检查 ==
中不会进行任何的类型转换,它们有自己独立的比较规则,所以除了它们自己与自己相等以及它们之间互等外,不会等于任何其他的值。这就解释了为什么(2)中 null == 0
会返回 false。
特立独行的 undefined
undefined
不应该被与其他值进行比较:
alert( undefined > 0 ); // false (1)alert( undefined < 0 ); // false (2)alert( undefined == 0 ); // false (3)
为何它看起来如此厌恶 0?返回值都是 false!
原因如下:
(1)
和(2)
都返回false
是因为undefined
在比较中被转换为了NaN
,而NaN
是一个特殊的数值型值,它与任何值进行比较都会返回false
。(3)
返回false
是因为这是一个相等性检查,而undefined
只与null
相等,不会与其他值相等。
连续比较
1 < 2 true; // 这里应该是1 < 2为true,true < 3的时候true转化为了1所以是true;3 < 2 true; // 这里应该是3 < 2为false,false < 1的时候false转化为了0所以是true;
总结
- 比较运算符(>、>=、<、<=、==、!=、===、!==)始终返回布尔值。
- 字符串的比较,会按照“词典”顺序逐字符地比较大小。
- 当对不同类型的值进行比较(不包括
===
与!==
)时,它们会先被转化为数字再进行比较。 - 在非严格相等
==
下,null
和undefined
相等且各自不等于任何其他的值。 - 在使用
>
或<
进行比较时,需要注意变量可能为null/undefined
的情况。比较好的方法是单独检查变量是否等于null/undefined
。
逻辑运算符
Js 中有四个逻辑运算符:||
(或),&&
(与),!
(非),??
(空值合并运算符)。
虽然它们被称为“逻辑”运算符,但这些运算符却可以被应用于任意类型的值,而不仅仅是布尔值。它们的结果也同样可以是任意类型。
运算优先级 : ! > && > ||
对于
或||
和与&&
运算:都是短路运算
或运算符做了如下的事情:
- 从左到右依次计算操作数。
- 处理每一个操作数时,都将其转化为布尔值。如果结果是
true
,就停止计算,返回这个操作数的初始值。 - 如果所有的操作数都被计算过(也就是,转换结果都是
false
),则返回最后一个操作数。
返回的值是操作数的初始形式,不会做布尔转换。
换句话说,一个或运算
||
的链,将返回第一个真值,如果不存在真值,就返回该链的最后一个值。例如:
alert( 1 || 0 ); // 1(1 是真值)alert( null || 1 ); // 1(1 是第一个真值)alert( null || 0 || 1 ); // 1(第一个真值)alert( undefined || null || 0 ); // 0(都是假值,返回最后一个值)
两个非运算
!!
有时候用来将某个值转化为布尔类型,功能类似于内建函数Boolean()
重要示例:
alert( alert(1) || 2 || alert(3) );
对 alert
的调用没有返回值,或者说返回的是 undefined
。
- 第一个或运算
||
对它的左值alert(1)
进行了计算。这就显示了第一条信息1
。 - 函数
alert
返回了undefined
,所以或运算继续检查第二个操作数以寻找真值。 - 第二个操作数
2
是真值,所以执行就中断了。2
被返回,并且被外层的 alert 显示。
这里不会显示 3
,因为运算没有抵达 alert(3)
。
空值合并运算符 ‘??’
??
提供了一种从列表中选择第一个“已定义的”值(值既不是null也不是undefined)的简便方式。
它被用于为变量分配默认值:
// 当 height 的值为 null 或 undefined 时,将 height 的值设置为 100height = height ?? 100;
与 || 运算符 比较
它们之间重要的区别是:
||
返回第一个 真 值。??
返回第一个 已定义的 值。
换句话说,
||
无法区分false
、0
、空字符串""
和null/undefined
。它们都一样 —— 假值。如果其中任何一个是||
的第一个参数,那么我们将得到第二个参数作为结果。??
运算符的优先级非常低,仅略高于?
和=
,因此在表达式中使用它时请考虑添加括号。如果没有明确添加括号,不能将其与
||
或&&
一起使用。
循环:While和for
三种循环:
while
—— 每次迭代之前都要检查条件。do...while
—— 每次迭代后都要检查条件。for (;;)
—— 每次迭代之前都要检查条件,可以使用其他设置。
通常使用 while(true)
来构造“无限”循环。
“无限”循环和其他循环一样,都可以通过 break
指令来终止。
如果我们不想在当前迭代中做任何事,并且想要转移至下一次迭代,那么可以使用 continue
指令。
标签是 break/continue
跳出嵌套循环以转到外部的唯一方法。
标签 是在循环之前带有冒号的标识符:
labelName: for (...) { ...}
break
语句跳出循环至标签处:
outer: for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { let input = prompt(`Value at coords (${i},${j})`, ''); // 如果是空字符串或被取消,则中断并跳出这两个循环。 if (!input) break outer; // (*) // 用得到的值做些事…… }}alert('Done!');
上述代码中,break outer
向上寻找名为 outer
的标签并跳出当前循环。因此,控制权直接从 (*)
转至 alert('Done!')
。
函数函数声明
function name(parameters, delimited, by, comma) { /* code */}
作为参数传递给函数的值,会被复制到函数的局部变量。
函数可以访问外部变量。但它只能从内到外起作用。函数外部的代码看不到函数内的局部变量。
函数可以返回值。如果没有返回值,则其返回的结果是
undefined
。默认值 (ES6)
如果一个函数被调用,但有参数(argument)未被提供,那么相应的值就会变成
undefined
。可以使用
=
为函数声明中的参数指定所谓的“默认”(如果对应参数的值未被传递则使用)值:function showMessage(from, text = "no text given") {alert( from + ": " + text );}showMessage("Ann"); // Ann: no text given// 等同于 showMessage("Ann", undefined)
如果非尾部的参数设置默认值,实际上这个参数是没法省略的,要显式设置为undefined,null没有这个效果
默认参数的计算
这里
"no text given"
是一个字符串,但它可以是更复杂的表达式,并且只会在缺少参数时才会被计算和分配。所以,这也是可能的:function showMessage(from, text = anotherFunction()) { // anotherFunction() 仅在没有给定 text 时执行,其运行结果将成为 text 的值}
在 Js 老代码中的默认参数
ES6 前,Js 不支持默认参数的语法。所以人们使用其他方式来设置默认参数。
如今,我们会在旧代码中看到它们。
例如,显式地检查
undefined
:function showMessage(from, text) { if (text === undefined) { text = 'no text given'; } alert( from + ": " + text );}
或者使用
||
运算符:function showMessage(from, text) { // 如果 text 的值为假值,则分配默认值 // 这样赋值 text == "" 与 text 无值相同 text = text || 'no text given'; ...}
后备的默认参数
现代 Js 引擎支持 [空值合并运算符]
??
,它在大多数假值(例如0
)应该被视为“正常值”时更具优势:function showCount(count) { // 如果 count 为 undefined 或 null,则提示 "unknown" alert(count ?? "unknown");}showCount(0); // 0showCount(null); // unknownshowCount(); // unknown
为了使代码简洁易懂,建议在函数中主要使用局部变量和参数,而不是外部变量。
与不获取参数但将修改外部变量作为副作用的函数相比,获取参数、使用参数并返回结果的函数更容易理解。
函数命名
- 函数名应该清楚地描述函数的功能。当我们在代码中看到一个函数调用时,一个好的函数名能够让我们马上知道这个函数的功能是什么,会返回什么。
- 一个函数是一个行为,所以函数名通常是动词。
- 目前有许多优秀的函数名前缀,如
create…
、show…
、get…
、check…
等等。使用它们来提示函数的作用吧。
函数表达式
使用函数的方法有两个:
- 使用函数声明
- 使用函数表达式
注意:
函数是值。它们可以在代码的任何地方被分配,复制或声明。
如果函数在主代码流中被声明为单独的语句,则称为“函数声明”。
如果该函数是作为表达式的一部分创建的,则称其“函数表达式”。
在执行代码块之前,内部算法会先处理函数声明。所以函数声明在其被声明的代码块内的任何位置都是可见的。但严格模式下,当一个函数声明在一个代码块内时,它在该代码块内的任何位置都是可见的。但在代码块外不可见。
let age = prompt("What is your age?", 18);// 有条件地声明一个函数if (age < 18) { function welcome() { alert("Hello!"); }} else { function welcome() { alert("Greetings!"); }}// ……稍后使用welcome(); // Error: welcome is not defined
函数表达式在执行流程到达时创建。
在大多数情况下,当我们需要声明一个函数时,最好使用函数声明,因为函数在被声明之前也是可见的。这使我们在代码组织方面更具灵活性,通常也会使得代码可读性更高。
所以,仅当函数声明不适合对应的任务时,才应使用函数表达式。
箭头函数(ES6)
箭头函数对于简单的操作很方便,特别是对于单行的函数。它具体有两种形式:
- 不带花括号:
(...args) => expression
—— 右侧是一个表达式:函数计算表达式并返回其结果。如果只有一个参数,则可以省略括号,例如n => n*2
。 - 带花括号:
(...args) => { body }
—— 花括号允许我们在函数中编写多个语句,但是需要显式地return
来返回一些内容。
使用注意点
箭头函数有几个使用注意点:
(1)以下4个变量在箭头函数之中是不存在的,其指向外层函数的对应变量:
this
:this
在箭头函数之中是不存在的,其指向外层函数的对应变量。所以箭头函数体内的this
对象,就是定义时所在的对象,而不是使用时所在的对象。arguments
:建议不要在箭头函数中使用arguments
,可以用 rest 参数代替。super
new.target
(2)因为箭头函数没有this
,所以也就不能用作构造函数,也就是说,不可以使用new
命令,否则会抛出一个错误。
(3)由于箭头函数没有自己的this
,所以当然也就不能用call()
、apply()
、bind()
这些方法去改变this
的指向。
(4)不可以使用yield
命令,因此箭头函数不能用作 Generator 函数。
不适用场合
由于箭头函数使得this
从“动态”变成“静态”,下面2个场合不应该使用箭头函数:
第1个场合是定义对象的方法,且该方法内部包括
this
:第2个场合是需要动态
this
的时候,也不应使用箭头函数。var button = document.getElementById('press');button.addEventListener('click', () => { this.classList.toggle('on');});
上面代码运行时,点击按钮会报错,因为
button
的监听函数是一个箭头函数,导致里面的this
就是全局对象。如果改成普通函数,this
就会动态指向被点击的按钮对象。
Rest 参数与 Spread 语法 (ES6)
当我们在函数中看到 "..."
时,它要么是 rest 参数,要么是 spread 语法。
有一个简单的方法可以区分它们:
- 若
...
出现在函数参数列表的最后,那么它就是 rest 参数,它会把参数列表中剩余的参数收集到一个数组中。 - 若
...
出现在函数调用或类似的表达式中,那它就是 spread 语法,它会把一个数组(或者可迭代对象)展开为列表。
使用场景:
- Rest 参数用于创建可接受任意数量参数的函数。
- Spread 语法用于将数组(或者可迭代对象)传递给通常需要含有许多参数的函数。
我们可以使用这两种语法轻松地互相转换列表与参数数组。
旧式的 arguments
(类数组且可迭代的对象)也依然能够帮助我们获取函数调用中的所有参数。
变量作用域,闭包 (ES6)
let、const 具有块级作用域
在 Js 中,闭包(closure)是指一个函数能够访问并记住它被创建时的词法环境,即使该函数在其词法作用域之外执行。
闭包由两个部分组成:函数本身和函数创建时的词法环境(隐藏的 [[Environment]]
属性)。词法环境是指在函数定义时存在的变量集合,包括函数内部声明的变量、函数参数以及外部作用域中的变量。闭包可以捕获和存储这些变量的引用,即使函数在定义后被调用或者返回出去时,仍然可以访问这些变量。
闭包的一个常见用途是创建私有变量。通过将变量定义在外部函数中,并在内部函数中引用这些变量,可以实现对这些变量的私有性保护,防止外部代码直接访问或修改这些变量。
例题一
函数 sayHi 使用外部变量。当函数运行时,将使用哪个值?
let name = "John";function sayHi() { alert("Hi, " + name);}name = "Pete";sayHi(); // 会显示什么:"John" 还是 "Pete"?
答案:Pete。
函数将从内到外依次在对应的词法环境中寻找目标变量,它使用最新的值。
旧变量值不会保存在任何地方。当一个函数想要一个变量时,它会从自己的词法环境或外部词法环境中获取当前值。
例题二
用相同的 makeCounter
函数创建了两个计数器(counters):counter
和 counter2
。
它们是独立的吗?第二个 counter 会显示什么?0,1
或 2,3
还是其他?
function makeCounter() { let count = 0; return function() { return count++; };}let counter = makeCounter();let counter2 = makeCounter();alert( counter() ); // 0alert( counter() ); // 1alert( counter2() ); // ?alert( counter2() ); // ?
答案是:0,1。
函数 counter
和 counter2
是通过 makeCounter
的不同调用创建的。
因此,它们具有独立的外部词法环境,每一个都有自己的 count
。
例题三
let x = 1;function func() { console.log(x); // ReferenceError: Cannot access 'x' before initialization let x = 2;}func();
在这个例子中,可以观察到“不存在”的变量和“未初始化”的变量之间的特殊差异。
从程序执行进入代码块(或函数)的那一刻起,变量就开始进入“未初始化”状态。它一直保持未初始化状态,直至程序执行到相应的 let
语句。
换句话说,一个变量从技术的角度来讲是存在的,但是在 let
之前还不能使用。
function func() { // 引擎从函数开始就知道局部变量 x, // 但是变量 x 一直处于“未初始化”(无法使用)的状态,直到结束 let(“死区”) // 因此答案是 error console.log(x); // ReferenceError: Cannot access 'x' before initialization let x = 2; // 如果声明改为var x = 2; 由于变量提升,不会报错,而是会打印出undefined}
变量暂时无法使用的区域(从代码块的开始到 let
)有时被称为“死区”。
Let 和 Const 命令let 命令基本用法
let
用来声明变量,用法类似于var,但let
只在其代码块内有效。
for
循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
for (let i = 0; i < 3; i++) { let i = 'abc'; console.log(i);}
上面代码正确运行,输出了 3 次abc
。这表明函数内部的变量i
与循环变量i
不在同一个作用域,有各自单独的作用域(同一个作用域不可使用 let
重复声明同一个变量)。
不存在变量提升
var
命令会发生“变量提升”现象,即变量可以在声明之前使用,值为undefined
。为了纠正这种现象,let
命令改变了语法行为,它所声明的变量一定要在声明后使用,否则报错。
暂时性死区
暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。
只要块级作用域内存在let
命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
var tmp = 123;if (true) { tmp = 'abc'; // ReferenceError let tmp;}
上面代码中,存在全局变量tmp
,但是块级作用域内let
又声明了一个局部变量tmp
,导致后者绑定这个块级作用域,所以在let
声明变量前,对tmp
赋值会报错。
例子一
“暂时性死区”也意味着typeof
不再是一个百分之百安全的操作。
typeof x; // ReferenceErrorlet x;
上面代码中,变量x
使用let
命令声明,所以在声明之前,都属于x
的“死区”,只要用到该变量就会报错。因此,typeof
运行时就会抛出一个ReferenceError
。
作为比较,如果一个变量根本没有被声明,使用typeof
反而不会报错。
typeof undeclared_variable // "undefined"
例子二
// 不报错var x = x;// 报错let x = x;// ReferenceError: x is not defined
上面代码报错,也是因为暂时性死区。使用let
声明变量时,只要变量在还没有声明完成前使用,就会报错。上面这行就属于这个情况,在变量x
的声明语句还没有执行完成前,就去取x
的值,导致报错”x 未定义“。
不允许重复声明
let
不允许在相同作用域内,重复声明同一个变量。
function func(arg) { { let arg; }}func() // 不报错
块级作用域
ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let
,在块级作用域之外不可引用。
如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。
- 允许在块级作用域内声明函数。
- 函数声明类似于
var
,即会提升到全局作用域或函数作用域的头部。 - 同时,函数声明还会提升到所在的块级作用域的头部。
注意,上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let
处理。
function f() { console.log('I am outside!'); }(function () { function f() { console.log('I am inside!'); } if (false) { } f();}());/*根据这三条规则,浏览器的 ES6 环境中,块级作用域内声明的函数,行为类似于`var`声明的变量。上面的例子实际运行的代码如下。*/// 浏览器的 ES6 环境function f() { console.log('I am outside!'); }(function () { var f = undefined; if (false) { function f() { console.log('I am inside!'); } } f();}());// Uncaught TypeError: f is not a function
考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
还有一个需要注意的地方。ES6 的块级作用域必须有大括号,如果没有大括号,Js 引擎就认为不存在块级作用域。
const 命令
const
声明一个只读的常量。一旦声明,常量的值就不能改变。
- 一旦声明变量,就必须立即初始化,不能留到以后赋值。
- 声明的常量不提升,存在暂时性死区,只能在声明的位置后面使用。
- 声明的常量,与
let
一样不可重复声明。
本质
const
实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const
只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。
可以通过将对象冻结来实现对象属性的不可修改操作,除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。
var constantize = (obj) => { Object.freeze(obj); Object.keys(obj).forEach( (key, i) => { if ( typeof obj[key] === 'object' ) { constantize( obj[key] ); } });};
ES6 声明变量的六种方法
ES5 :两种 —— var
命令和function
命令。
ES6 :六种 —— var
命令和function
命令,let
和const
命令,import
命令和class
命令。
老旧的 “var”
var
与 let/const
有两个主要的区别:
var
声明的变量没有块级作用域,它们仅在当前函数内可见,或者全局可见(如果变量是在函数外声明的)。var
变量声明在函数开头就会被处理(脚本启动对应全局变量),变量提升。
函数对象,NFE
函数的类型是对象。
属性
name
—— 函数的名字。通常取自函数定义,但如果函数定义时没设定函数名,Js 会尝试通过函数的上下文猜一个函数名(例如把赋值的变量名取为函数名)。length
—— 函数定义时的入参的个数。Rest 参数不参与计数。
如果函数是通过函数表达式的形式被声明的(不是在主代码流里),并且附带了名字,那么它被称为命名函数表达式(Named Function Expression)。这个名字可以用于在该函数内部进行自调用,例如递归调用等。
此外,函数可以带有额外的属性。很多知名的 Js 库都充分利用了这个功能。
它们创建一个“主”函数,然后给它附加很多其它“辅助”函数。例如:
- jQuery 库创建了一个名为
$
的函数。 - lodash 库创建一个
_
函数,然后为其添加了_.add
、_.keyBy
以及其它属性 - 实际上,它们这么做是为了减少对全局空间的污染,这样一个库就只会有一个全局变量。这样就降低了命名冲突的可能性。
尾调用优化什么是尾调用?
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
function f(x){ return g(x);}
尾调用优化
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A
的内部调用函数B
,那么在A
的调用帧上方,还会形成一个B
的调用帧。等到B
运行结束,将结果返回到A
,B
的调用帧才会消失。如果函数B
内部还调用函数C
,那就还有一个C
的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
function f() { let m = 1; let n = 2; return g(m + n);}f();// 等同于function f() { return g(3);}f();// 等同于g(3);
上面代码中,如果函数g
不是尾调用,函数f
就需要保存内部变量m
和n
的值、g
的调用位置等信息。但由于调用g
之后,函数f
就结束了,所以执行到最后一步,完全可以删除f(x)
的调用帧,只保留g(3)
的调用帧。
这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
严格模式
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
func.arguments
:返回调用时函数的参数。func.caller
:返回调用当前函数的那个函数。
函数参数的尾逗号 (ES2017)
允许函数的最后一个参数有尾逗号(trailing comma)
此前,函数定义和调用时,都不允许最后一个参数后面出现逗号。
Function.prototype.toString() (ES2019)
对函数实例的toString()
方法做出了修改,以前会省略注释和空格,现在明确要求返回一模一样的原始代码。
“new Function” 语法
语法:
let func = new Function ([arg1, arg2, ...argN], functionBody);
由于历史原因,参数也可以按逗号分隔符的形式给出。
以下三种声明的含义相同:
new Function('a', 'b', 'return a + b'); // 基础语法new Function('a,b', 'return a + b'); // 逗号分隔new Function('a , b', 'return a + b'); // 逗号和空格分隔
使用 new Function
创建的函数,它的 [[Environment]]
指向全局词法环境,而不是函数所在的外部词法环境。因此,我们不能在 new Function
中直接使用外部变量。这有助于降低我们代码出错的可能。并且,从代码架构上讲,显式地使用参数传值是一种更好的方法,并且避免了与使用压缩程序而产生冲突的问题。
Object 对象对象
对象存储属性(键值对),其中:
- 属性的键必须是字符串或者 symbol(通常是字符串)。
- 值可以是任何类型。
使用下面的方法访问属性:
- 点符号:
obj.property
。 - 方括号
obj["property"]
,方括号允许从变量中获取键,例如obj[varWithKey]
。
其他操作:
- 删除属性:
delete obj.prop
。 - 检查是否存在给定键的属性:
"key" in obj
。 - 遍历对象:
for(let key in obj)
循环。
Js 中还有很多其他类型的对象:
Array
用于存储有序数据集合Date
用于存储时间日期Error
用于存储错误信息
它们有着各自特别的特性,有时候大家会说“Array 类型”或“Date 类型”,但其实它们并不是自身所属的类型,而是属于一个对象类型即 “object”。
对象引用
对象通过引用被赋值和拷贝。换句话说,一个变量存储的不是“对象的值”,而是一个对值的“引用”(内存地址)。
因此,拷贝此类变量或将其作为函数参数传递时,所拷贝的是引用,而不是对象本身。
所有通过被拷贝的引用的操作(如添加、删除属性)都作用在同一个对象上。
为了创建“真正的拷贝”(一个克隆),使用 Object.assign
来做“浅拷贝”(嵌套对象被通过引用进行拷贝)或者使用“深拷贝”函数_.cloneDeep(obj) (存在于Lodash模块中)。
语法是:
Object.assign(dest, [src1, src2, src3...])
- 第一个参数
dest
是指目标对象。 - 后面的参数
src1, ..., srcN
(可按需传递多个参数)是源对象。 - 该方法将所有源对象的属性拷贝到目标对象
dest
中。换句话说,从第二个开始的所有参数的属性都被拷贝到第一个参数的对象中。(如果对象中的属性出现重复,以最重复后面的属性的值作为最终的值) - 调用结果返回
dest
。
垃圾回收
- 垃圾回收是自动完成的,我们不能强制执行或是阻止执行。
- 当对象是可达状态时,它一定是存在于内存中的。
- 被引用与可访问(从一个根)不同:一组相互连接的对象可能整体都不可达
- 对外引用不重要,只有传入引用才可以使对象可达。
- 几个对象相互引用,但外部没有对其任意对象的引用,这些对象也可能是不可达的,并被从内存中删除。
对象方法,”this”
存储在对象属性中的函数被称为“方法”。
方法允许对象进行像
object.doSomething()
这样的“操作”。方法可以将对象引用看为
this
,this 的值是在程序运行时得到的。一个函数在声明时,可能就使用了this
,但是这个this
只有在函数被调用时才会有值。可以在对象之间复制函数。
以“方法”的语法调用函数时:
object.method()
,调用过程中的this
值是object
。
请注意箭头函数有些特别:它们没有 this
。在箭头函数内部访问到的 this
都是从外部获取的。
function makeUser() { return { name: "John", ref: this };}let user = makeUser();alert( user.ref.name ); // Error: Cannot read property 'name' of undefined
这是因为设置 this
的规则不考虑对象定义。只有调用那一刻才重要。
个人理解如下:函数调用makeUser()
就已经决定了该函数中的this的值,因为它是直接调用,不是通过点符号作为方法调用,故此时函数中的this
为 undefined
,故其实返回的对象中的ref属性就已经为undefined
了。
function makeUser() { return { name: "John", ref() { return this; } };}let user = makeUser();alert( user.ref().name ); // John
现在正常了,因为 user.ref()
是一个方法。this
的值为点符号 .
前的这个对象。
构造器和操作符 “new”
构造函数,或简称构造器,就是常规函数,但大家对于构造器有个共同的约定,就是其命名首字母要大写。
构造函数只能使用
new
来调用。- 一个新的空对象被创建并分配给
this
。 - 函数体执行。通常它会修改
this
,为其添加新的属性。 - 返回
this
的值。
- 一个新的空对象被创建并分配给
构造器模式测试:new.target
在一个函数内部,我们可以使用
new.target
属性来检查它是否被使用new
进行调用了。对于常规调用,它为 undefined,对于使用
new
的调用,则等于该构造函数:function User() { alert(new.target);}// 不带 "new":User(); // undefined// 带 "new":new User(); // function User { ... }
我们可以使用构造函数来创建多个类似的对象。
对象属性配置属性标志和属性描述符属性标志
对象属性(properties),除 value
外,还有三个特殊的特性(attributes),即所谓的“标志”:
writable
— 如果为true
,则值可以被修改,否则只可读的。enumerable
— 如果为true
,则会被在循环中列出,否则不会被列出。configurable
— 如果为true
,则此属性可以被删除,这些特性也可以被修改,否则不可以。当
configurable
设置为false
,唯一可行的特性更改:writable true → false
对于不可配置的属性,我们可以将
writable: true
更改为false
,从而防止其值被修改(以添加另一层保护)。但无法反向行之。
当用“常用的方式”创建一个属性时(user.age = 18),标志都为 true
。但也可以随时更改它们。
获得标志
let user = { name: "John"};// 语法:let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);// 返回值是一个所谓的“属性描述符”对象:它包含值和所有的标志。// 要一次获取所有属性描述符,我们可以使用 Object.getOwnPropertyDescriptors(obj) 方法。let descriptor = Object.getOwnPropertyDescriptor(user, 'name');alert( JSON.stringify(descriptor, null, 2 ) );/* 属性描述符:{ "value": "John", "writable": true, "enumerable": true, "configurable": true}*/
修改标志:
let user = {};Object.defineProperty(user, "name", { value: "John"});// 语法:Object.defineProperty(obj, propertyName, descriptor)// 如果该属性存在,defineProperty 会更新其标志。否则,它会使用给定的值和标志创建属性;在这种情况下,如果没有提供标志,则会假定它是 false。// 一次配置多个属性/* Object.defineProperties(obj, { prop1: descriptor1, prop2: descriptor2 // ...});*/
克隆对象
通常,当克隆一个对象时,使用赋值的方式来复制属性
for (let key in user) { clone[key] = user[key]}
新方法:
// 克隆 obj 这个对象let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj));
区别:
- 旧方法并不能复制标志。所以如果想要一个“更好”的克隆,那么
Object.defineProperties
是首选。 for..in
会忽略 symbol 类型的和不可枚举的属性,但是Object.getOwnPropertyDescriptors
返回包含 symbol 类型的和不可枚举的属性在内的 所有 属性描述符。
设定一个全局的密封对象
属性描述符在单个属性的级别上工作。
还有一些限制访问 整个 对象的方法:
Object.preventExtensions(obj) —— 禁止向对象添加新属性。
Object.seal(obj) —— 禁止添加/删除属性。为所有现有的属性设置
configurable: false
。Object.freeze(obj) —— 禁止添加/删除/更改属性。为所有现有的属性设置
configurable: false, writable: false
。
还有针对它们的测试:
Object.isExtensible(obj) —— 如果添加属性被禁止,则返回
false
,否则返回true
。Object.isSealed(obj) —— 如果添加/删除属性被禁止,并且所有现有的属性都具有
configurable: false
则返回true
。Object.isFrozen(obj) —— 如果添加/删除/更改属性被禁止,并且所有当前属性都是
configurable: false, writable: false
,则返回true
。
属性的 getter 和 setter对象属性分为两类:
- 数据属性。到目前为止,我们使用过的所有属性都是数据属性。
- 访问器属性(accessor property)。本质上是用于获取和设置值的函数,但从外部代码来看就像常规属性。
访问器描述符
访问器属性的描述符与数据属性的不同。
对于访问器属性,没有 value
和 writable
,但是有 get
和 set
函数。
访问器描述符:
get
—— 一个没有参数的函数,在读取属性时工作,set
—— 带有一个参数的函数,当属性被设置时调用,enumerable
—— 与数据属性的相同,configurable
—— 与数据属性的相同。
let user = { name: "John", surname: "Smith"};// 请注意,一个属性要么是访问器属性(具有 get/set 方法),要么是数据属性(具有 value),但不能两者都是。如果我们试图在同一个描述符中同时提供 get 和 value,则会出现错误。Object.defineProperty(user, 'fullName', { get() { return `${this.name} ${this.surname}`; }, set(value) { [this.name, this.surname] = value.split(" "); }});alert(user.fullName); // John Smithfor(let key in user) alert(key); // name, surname
可选链 “?.”
可选链 ?.
语法有三种形式:
obj?.prop
—— 如果obj
存在则返回obj.prop
,否则返回undefined
。obj?.[prop]
—— 如果obj
存在则返回obj[prop]
,否则返回undefined
。obj.method?.()
—— 如果obj.method
存在则调用obj.method()
,否则返回undefined
。
正如我们所看到的,这些语法形式用起来都很简单直接。?.
检查左边部分是否为 null/undefined
,如果不是则继续运算。
?.
链使我们能够安全地访问嵌套属性。
Symbol 类型
“symbol” 值表示唯一的标识符。
可以使用 Symbol()
来创建这种类型的值:
let id = Symbol();
创建时,我们可以给 symbol 一个描述(也称为 symbol 名),这在代码调试时非常有用:
// id 是描述为 "id" 的 symbollet id = Symbol("id");
symbol 保证是唯一的。即使我们创建了许多具有相同描述的 symbol,它们的值也是不同。描述只是一个标签,不影响任何东西。
例如,这里有两个描述相同的 symbol —— 它们不相等:
let id1 = Symbol("id");let id2 = Symbol("id");alert(id1 == id2); // false
symbol 不会被自动转换为字符串
Js 中的大多数值都支持字符串的隐式转换。
例如,我们可以 alert
任何值,都可以生效。symbol 比较特殊,它不会被自动转换。但下面这个 alert
将会提示出错:
let id = Symbol("id");alert(id); // 类型错误:无法将 symbol 值转换为字符串。
这是一种防止混乱的“语言保护”,因为字符串和 symbol 有本质上的不同,不应该意外地将它们转换成另一个。
如果我们真的想显示一个 symbol,我们需要在它上面调用 .toString()
,如下所示:
let id = Symbol("id");alert(id.toString()); // Symbol(id),现在它有效了
或者获取 symbol.description
属性,只显示描述(description):
let id = Symbol("id");alert(id.description); // id
如果我们要在对象字面量 {...}
中使用 symbol,则需要使用方括号把它括起来。
let id = Symbol("id");let user = { name: "John", [id]: 123 // 而不是 "id":123};
如果我们希望同名的 symbol 相等,那么我们应该使用全局注册表。
要从注册表中读取(不存在则创建)symbol,请使用 Symbol.for(key)
。
该调用会检查全局注册表,如果有一个描述为 key
的 symbol,则返回该 symbol,否则将创建一个新 symbol(Symbol(key)
),并通过给定的 key
将其存储在注册表中。
Symbol.keyFor
内部使用全局 symbol 注册表来查找 symbol 的键。所以它不适用于非全局 symbol。如果 symbol 不是全局的,它将无法找到它并返回 undefined
。
symbol 有两个主要的使用场景:
“隐藏” 对象属性。
如果我们想要向“属于”另一个脚本或者库的对象添加一个属性,我们可以创建一个 symbol 并使用它作为属性的键。symbol 属性不会出现在
for..in
中,因此它不会意外地被与其他属性一起处理。并且,它不会被直接访问,因为另一个脚本没有我们的 symbol。因此,该属性将受到保护,防止被意外使用或重写。因此我们可以使用 symbol 属性“秘密地”将一些东西隐藏到我们需要的对象中,但其他地方看不到它。
相反,Object.assign 会同时复制字符串和 symbol 属性:
JavaScript 使用了许多系统 symbol,这些 symbol 可以作为
Symbol.*
访问。我们可以使用它们来改变一些内建行为。例如,在本教程的后面部分,我们将使用Symbol.iterator
来进行 迭代 操作,使用Symbol.toPrimitive
来设置 对象原始值的转换 等等。
从技术上说,symbol 不是 100% 隐藏的。有一个内建方法 Object.getOwnPropertySymbols(obj) 允许我们获取所有的 symbol。还有一个名为 Reflect.ownKeys(obj) 的方法可以返回一个对象的 所有 键,包括 symbol。但大多数库、内建方法和语法结构都没有使用这些方法。
对象 —— 原始值转换
对象到原始值的转换,是由许多期望以原始值作为值的内建函数和运算符自动调用的。
这里有三种类型(hint):
"string"
(对于alert
和其他需要字符串的操作)"number"
(对于数学运算)"default"
(少数运算符,通常对象以和"number"
相同的方式实现"default"
转换)
规范明确描述了哪个运算符使用哪个 hint。
转换算法是:
调用
obj[Symbol.toPrimitive](hint)
如果这个方法存在,let user = { name: "John", money: 1000, [Symbol.toPrimitive](hint) { alert(`hint: ${hint}`); return hint == "string" ? `{name: "${this.name}"}` : this.money; }};// 转换演示:alert(user); // hint: string -> {name: "John"}alert(+user); // hint: number -> 1000alert(user + 500); // hint: default -> 1500
否则,如果 hint 是 “string”
尝试调用
obj.toString()
或obj.valueOf()
,无论哪个存在。默认情况下:
/* 默认情况下,普通对象具有 toString 和 valueOf 方法: toString 方法返回一个字符串 "[object Object]"。 valueOf 方法返回对象自身。*/let user = {name: "John"};alert(user); // [object Object]alert(user.valueOf() === user); // true
否则,如果 hint 是 “number”或者”default”
- 尝试调用
obj.valueOf()
或obj.toString()
,无论哪个存在。
- 尝试调用
所有这些方法都必须返回一个原始值才能工作(如果已定义)。
在实际使用中,通常只实现 obj.toString()
作为字符串转换的“全能”方法就足够了,该方法应该返回对象的“人类可读”表示,用于日志记录或调试。