JavaScript学习笔记01(包含ES6语法)

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 中正常运行。

引擎是如何工作的?

引擎很复杂,但是基本原理很简单。

  1. 引擎(如果是浏览器,则引擎被嵌入在其中)读取(“解析”)脚本。
  2. 然后,引擎将脚本转化(“编译”)为机器语言。
  3. 然后,机器代码快速地执行。

引擎会对流程中的每个阶段都进行优化。它甚至可以在编译的脚本运行时监视它,分析流经该脚本的数据,并根据获得的信息进一步优化机器代码。

浏览器中的 Js

能做什么?

现代的 Js 是一种“安全的”编程语言。它不提供对内存或 CPU 的底层访问,因为它最初是为浏览器创建的,不需要这些功能。

Js 的能力很大程度上取决于它运行的环境。例如,Node.js 支持允许 Js 读取/写入任意文件,执行网络请求等。

浏览器中的 Js 可以做下面这些事:

  • 在网页中添加新的 HTML,修改网页已有内容和网页的样式。
  • 响应用户的行为,响应鼠标的点击,按键的按动。
  • 向远程服务器发送网络请求,下载和上传文件(所谓的 AJAXCOMET 技术)。
  • 获取或设置 cookie,向访问者提出问题或发送消息。
  • 记住客户端的数据(“本地存储”)。

不能做什么?

为了用户的(信息)安全,在浏览器中的 Js 的能力是受限的。

目的是防止恶意网页获取用户私人信息或损害用户数据。

此类限制的例子包括:

  • 网页中的 Js 不能读、写、复制和执行硬盘上的任意文件。它没有直接访问操作系统的功能。

    现代浏览器允许 Js 做一些文件相关的操作,但是这个操作是受到限制的。

    仅当用户做出特定的行为,Js 才能操作这个文件。eg:用户把文件“拖放”到浏览器中,或者通过 <input type='file'> 标签选择了文件。

    有很多与相机/麦克风和其它设备进行交互的方式,但是这些都需要获得用户的明确许可。

  • 不同的标签页/窗口之间通常互不了解。

    有时候,也会有一些联系,例如一个标签页通过 Js 打开的另外一个标签页。

    但即使在这种情况下,如果两个标签页打开的不是同一个网站(域名、协议或者端口任一不相同的网站),它们都不能相互通信。这就是所谓的“同源策略”。

    为了解决“同源策略”问题,两个标签页必须都包含一些处理这个问题的特定的 Js 代码,并均允许数据交换。

  • Js 可以轻松地通过互联网与当前页面所在的服务器进行通信。但是从其他网站/域的服务器中接收数据的能力被削弱了。尽管可以,但是需要来自远程服务器的明确协议(在 HTTP header 中)。这也是为了用户的信息安全。

数据类型

在 Js 中有 8 种基本的数据类型(7 种原始类型和 1 种引用类型)

Number 类型

代表整数和浮点数,可以有很多操作,eg:乘法 *、除法 /、加法 +、减法 - 等等。

除了常规的数字,还包括所谓的“特殊数值”:Infinity-InfinityNaN

科学计数法

  • "e" 和 0 的数量附加到数字后。就像:123e6123 后面接 6 个 0 相同。
  • "e" 后面的负数将使数字除以 1 后面接着给定数量的零的数字。例如 123e-6 表示 0.000123123 的百万分之一)。

多种进制

  • 可以直接在十六进制(0x),八进制(0oor00)和二进制(0bor0B)系统中写入数字。

  • 使用Number()方法将含有对应前缀的字符串数值转为十进制

    Number('0b111')  // 7
    Number('0o10')  // 8
    
  • parseInt(str, base) 将字符串 str 解析为在给定的 base 数字系统中的整数,2 ≤ base ≤ 36

  • num.toString(base) 将数字转换为在给定的 base 数字系统中的字符串。

常规数字检测

全局方法

  • isNaN(value) —— 将其参数转换为数字,然后检测它是否为 NaN
  • isFinite(value) —— 将其参数转换为数字,如果它是常规数字,则返回 trueNaN/Infinity/-Infinity返回false)

定义在Number上的方法 (ES6)

  • Number.isNaN() ——检查一个值是否为NaN。如果参数类型不是NaNNumber.isNaN一律返回false

  • Number.isFinite() —— 检查一个数值是否为有限的。如果参数类型不是数值,Number.isFinite一律返回false

区别

传统的全局方法isFinite()isNaN()先调用Number()将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,Number.isFinite()对于非数值一律返回false, Number.isNaN()只有对于NaN才返回true,非NaN一律返回false

isFinite(25) // true
isFinite("25") // true
Number.isFinite(25) // true
Number.isFinite("25") // false

isNaN(NaN) // true
isNaN("NaN") // true
Number.isNaN(NaN) // true
Number.isNaN("NaN") // false
Number.isNaN(1) // false

不规则字符串转换为数字

任务:将 12pt100px 之类的值转换为数字

全局方法

  • 使用 parseInt/parseFloat 进行“软”转换,它从字符串中读取数字,然后返回在发生 error 前可以读取到的值。

定义在Number上的方法 (ES6)

  • ES6 将全局方法parseInt()parseFloat(),移植到Number对象上面,行为完全保持不变。这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。

Math 对象的扩展

  • 使用 Math.floorMath.ceilMath.truncMath.roundnum.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 的数值(所有进制)使用下划线(_)作为分隔符,这个数值分隔符没有指定间隔的位数。

使用注意点:

  • 不能放在数值的最前面或最后面。
  • 不能两个或两个以上的分隔符连在一起。
  • 小数点的前后不能有分隔符。
  • 科学计数法里面,表示指数的eE前后不能有分隔符。
  • 分隔符不能紧跟着进制的前缀0b0B0o0O0x0X(eg:0_b1100,0b_0100)

下面三个将字符串转成数值的函数,不支持数值分隔符:

  • Number()
  • parseInt()
  • parseFloat()

BigInt 类型

number 类型无法安全地表示大于 (253-1),或小于 - (253-1)的整数。

更准确的说:

“number” 类型可以存储更大的整数,但超出安全整数范围 ±(253-1)会出现精度问题,因为并非所有数字都适合固定的 64 位存储。因此,可能存储的是“近似值”。

// 尾部的 "n" 表示这是一个 BigInt 类型
const bigInt = 1234567890123456789012345678901234567890n;

String 类型

三种包含字符串的方式:

  1. 双引号:"Hello"

  2. 单引号:'Hello'

  3. 反引号:`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 返回一个空字符串

  • 获取子字符串,使用 slicesubstring

    方法 选择方式 负值参数
    slice(start, end) startend(不含 end),start可以比end大 允许
    substring(start, end) startend(不含 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

  • 查找子字符串时,使用 indexOfincludes/startsWith/endsWith (ES6)进行简单检查。

    这里检查是否找到子字符串时使用的一个技巧是 ~ 运算符。

    它将数字转换为 32-bit 整数(如果存在小数部分,则删除小数部分),然后对其二进制表示形式中的所有位均取反。实际上,这意味着一件很简单的事儿:对于 32-bit 整数,~n 等于 -(n+1)

    原因:在补码中,符号位不变,数值位 取反加1 得 -n ,表示为 -n = 取反 + 1 ,只取反为 ~n = -n - 1 = -(n+1)

    人们用它来简写 indexOf 检查:

    let str = "Widget";
    
    // 找到:返回值>0,不为-1
    if (~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 === 2
    arr.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'
    

    常见用途:

    1. 为数值补全指定位数
    2. 提示字符串格式
  • 实例方法: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 类型(逻辑类型)

仅包含两个值:truefalse

原始类型的方法

  • nullundefined 以外的原始类型都提供了许多有用的方法。

  • 从形式上讲,这些方法通过临时对象工作,但 Js 引擎可以很好地调整,以在内部对其进行优化,因此调用它们并不需要太高的成本。

    let str = "Hello";
    
    alert( str.toUpperCase() ); // HELLO
    

    以下是 str.toUpperCase() 中实际发生的情况:

    1. 字符串 str 是一个原始值。因此,在访问其属性时,会创建一个包含字符串字面值的特殊对象,并且具有可用的方法,例如 toUpperCase()
    2. 该方法运行并返回一个新的字符串(由 alert 显示)。
    3. 特殊对象被销毁,只留下原始值 str

    重要例子

    let str = "Hello";
    
    str.test = 5; // (*)
    
    alert(str.test);
    

    根据你是否开启了严格模式 use strict,会得到如下结果:

    1. undefined(非严格模式)
    2. 报错(严格模式)。

    为什么?让我们看看在 (*) 那一行到底发生了什么:

    1. 当访问 str 的属性时,一个“对象包装器”被创建了。
    2. 在严格模式下,向其写入内容会报错。
    3. 否则,将继续执行带有属性的操作,该对象将获得 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?!?" );
}

另一方面,调用不带关键字 newString/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 的调用会以字符串的形式返回数据类型。

  1. typeof null 的结果为 "object"这是官方承认的 typeof 的错误,这个问题来自于 Js 语言的早期阶段,并为了兼容性而保留了下来。null 绝对不是一个 objectnull 有自己的类型,它是一个特殊值。typeof 的行为在这里是错误的。
  2. 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

这些方法都是模态的:它们暂停脚本的执行,并且不允许用户与该页面的其余部分进行交互,直到窗口被解除。

上述所有方法共有两个限制:

  1. 模态窗口的确切位置由浏览器决定。通常在页面中心。
  2. 窗口的确切外观也取决于浏览器。我们不能修改它。

这就是简单的代价。还有其他一些方式可以显示更漂亮的窗口,并与用户进行更丰富的交互。

类型转换

有三种常用的类型转换:

字符串转换 —— 转换发生在输出内容的时候,也可以通过 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

  • 对于什么时候时,"+"是字符串的拼接还是加法:

    1. 若第一个操作数为字符串,即为字符串的拼接
    2. 若第一个操作数不是字符串,如为 true 这样的boolean型,即为加法

数字型转化:一元运算符 +

加号 + 有两种形式。

一种是二元运算符,另一种是一元运算符。

一元运算符加号 + 应用于单个值,对数字没有任何作用,但如果运算元不是数字,加号 + 则会将其转化为数字。它的效果和 Number(...) 相同,但是更加简短。

例如:

// 对数字无效
let y = -2;
alert( +y ); // -2

// 转化非数字
alert( +true ); // 1
alert( +"" );   // 0

值的比较

奇怪的结果:null vs 0

通过比较 null 和 0 可得:

alert( null > 0 );  // (1) false
alert( null == 0 ); // (2) false
alert( null >= 0 ); // (3) true

上面的结果完全打破了你对数学的认识。在最后一行代码显示 “null 大于等于 0 为 true ” 的情况下,前两行代码中一定会有一个是正确的,然而事实表明它们的结果都是 false。

为什么会出现这种反常结果,这是因为相等性检查 == 和普通比较符 > < >= <= 的代码逻辑是相互独立的。

进行值的比较时,null 会被转化为数字,因此它被转化为了 0

这就导致了(3)中 null >= 0 返回值是 true,(1)中 null > 0 返回值是 false。

undefinednull 在相等性检查 == 中不会进行任何的类型转换,它们有自己独立的比较规则,所以除了它们自己与自己相等以及它们之间互等外,不会等于任何其他的值。这就解释了为什么(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 在比较中被转换为了 NaNNaN 是一个特殊的数值型值,它与任何值进行比较都会返回 false
  • (3) 返回 false 是因为这是一个相等性检查,而 undefined 只与 null 相等,不会与其他值相等。

连续比较

1 < 2 < 3 --> true; 
// 这里应该是1 < 2为true,true < 3的时候true转化为了1所以是true;
3 < 2 < 1 --> true; 
// 这里应该是3 < 2为false,false < 1的时候false转化为了0所以是true;

总结

  • 比较运算符(>、>=、<、<=、==、!=、===、!==)始终返回布尔值。
  • 字符串的比较,会按照“词典”顺序逐字符地比较大小。
  • 当对不同类型的值进行比较(不包括 ===!==)时,它们会先被转化为数字再进行比较。
  • 在非严格相等 == 下,nullundefined 相等且各自不等于任何其他的值。
  • 在使用 >< 进行比较时,需要注意变量可能为 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

  1. 第一个或运算 || 对它的左值 alert(1) 进行了计算。这就显示了第一条信息 1
  2. 函数 alert 返回了 undefined,所以或运算继续检查第二个操作数以寻找真值。
  3. 第二个操作数 2 是真值,所以执行就中断了。2 被返回,并且被外层的 alert 显示。

这里不会显示 3,因为运算没有抵达 alert(3)

空值合并运算符 '??'

  • ?? 提供了一种从列表中选择第一个“已定义的”值(值既不是null也不是undefined)的简便方式。

它被用于为变量分配默认值:

// 当 height 的值为 null 或 undefined 时,将 height 的值设置为 100
height = height ?? 100;
  • 与 || 运算符 比较

    它们之间重要的区别是:

    • || 返回第一个 值。
    • ?? 返回第一个 已定义的 值。

    换句话说,|| 无法区分 false0、空字符串 ""null/undefined。它们都一样 —— 假值。如果其中任何一个是 || 的第一个参数,那么我们将得到第二个参数作为结果。

  • ?? 运算符的优先级非常低,仅略高于 ?=,因此在表达式中使用它时请考虑添加括号。

  • 如果没有明确添加括号,不能将其与 ||&& 一起使用。

循环:While和for

三种循环:

  • while —— 每次迭代之前都要检查条件。
  • do...while —— 每次迭代后都要检查条件。
  • for (;;) —— 每次迭代之前都要检查条件,可以使用其他设置。

通常使用 while(true) 来构造“无限”循环。

“无限”循环和其他循环一样,都可以通过 break 指令来终止。

如果我们不想在当前迭代中做任何事,并且想要转移至下一次迭代,那么可以使用 continue 指令。

标签是 break/continue 跳出嵌套循环以转到外部的唯一方法。

标签 是在循环之前带有冒号的标识符:

labelName: for (...) {
  ...
}

break <labelName> 语句跳出循环至标签处:

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); // 0
    showCount(null); // unknown
    showCount(); // 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)

箭头函数对于简单的操作很方便,特别是对于单行的函数。它具体有两种形式:

  1. 不带花括号:(...args) => expression —— 右侧是一个表达式:函数计算表达式并返回其结果。如果只有一个参数,则可以省略括号,例如 n => n*2
  2. 带花括号:(...args) => { body } —— 花括号允许我们在函数中编写多个语句,但是需要显式地 return 来返回一些内容

使用注意点

箭头函数有几个使用注意点:

(1)以下4个变量在箭头函数之中是不存在的,其指向外层函数的对应变量:

  • thisthis在箭头函数之中是不存在的,其指向外层函数的对应变量。所以箭头函数体内的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(类数组且可迭代的对象)也依然能够帮助我们获取函数调用中的所有参数。

img

变量作用域,闭包 (ES6)

let、const 具有块级作用域

在 Js 中,闭包(closure)是指一个函数能够访问并记住它被创建时的词法环境,即使该函数在其词法作用域之外执行。

闭包由两个部分组成:函数本身和函数创建时的词法环境(隐藏的 [[Environment]] 属性)。词法环境是指在函数定义时存在的变量集合,包括函数内部声明的变量、函数参数以及外部作用域中的变量。闭包可以捕获和存储这些变量的引用,即使函数在定义后被调用或者返回出去时,仍然可以访问这些变量。

闭包的一个常见用途是创建私有变量。通过将变量定义在外部函数中,并在内部函数中引用这些变量,可以实现对这些变量的私有性保护,防止外部代码直接访问或修改这些变量。

例题一

函数 sayHi 使用外部变量。当函数运行时,将使用哪个值?

let name = "John";

function sayHi() {
  alert("Hi, " + name);
}

name = "Pete";

sayHi(); // 会显示什么:"John" 还是 "Pete"?

答案:Pete

函数将从内到外依次在对应的词法环境中寻找目标变量,它使用最新的值。

旧变量值不会保存在任何地方。当一个函数想要一个变量时,它会从自己的词法环境或外部词法环境中获取当前值。

例题二

用相同的 makeCounter 函数创建了两个计数器(counters):countercounter2

它们是独立的吗?第二个 counter 会显示什么?0,12,3 还是其他?

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

答案是:0,1。

函数 countercounter2 是通过 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; // ReferenceError
let 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命令,letconst命令,import命令和class命令。

老旧的 "var"

varlet/const 有两个主要的区别:

  1. var 声明的变量没有块级作用域,它们仅在当前函数内可见,或者全局可见(如果变量是在函数外声明的)。
  2. 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运行结束,将结果返回到AB的调用帧才会消失。如果函数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就需要保存内部变量mn的值、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() 这样的“操作”。

  • 方法可以将对象引用看为 thisthis 的值是在程序运行时得到的。一个函数在声明时,可能就使用了 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的值,因为它是直接调用,不是通过点符号作为方法调用,故此时函数中的thisundefined,故其实返回的对象中的ref属性就已经为undefined了。

function makeUser() {
  return {
    name: "John",
    ref() {
      return this;
    }
  };
}

let user = makeUser();

alert( user.ref().name ); // John

现在正常了,因为 user.ref() 是一个方法。this 的值为点符号 . 前的这个对象。

构造器和操作符 "new"

  • 构造函数,或简称构造器,就是常规函数,但大家对于构造器有个共同的约定,就是其命名首字母要大写。

  • 构造函数只能使用 new 来调用。

    1. 一个新的空对象被创建并分配给 this
    2. 函数体执行。通常它会修改 this,为其添加新的属性。
    3. 返回 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.isExtensible(obj) —— 如果添加属性被禁止,则返回 false,否则返回 true

  • Object.isSealed(obj) —— 如果添加/删除属性被禁止,并且所有现有的属性都具有 configurable: false则返回 true

  • Object.isFrozen(obj) —— 如果添加/删除/更改属性被禁止,并且所有当前属性都是 configurable: false, writable: false,则返回 true

属性的 getter 和 setter

对象属性分为两类:

  1. 数据属性。到目前为止,我们使用过的所有属性都是数据属性。
  2. 访问器属性(accessor property)。本质上是用于获取和设置值的函数,但从外部代码来看就像常规属性。

访问器描述符

访问器属性的描述符与数据属性的不同。

对于访问器属性,没有 valuewritable,但是有 getset 函数。

访问器描述符:

  • 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 Smith

for(let key in user) alert(key); // name, surname

可选链 "?."

可选链 ?. 语法有三种形式:

  1. obj?.prop —— 如果 obj 存在则返回 obj.prop,否则返回 undefined
  2. obj?.[prop] —— 如果 obj 存在则返回 obj[prop],否则返回 undefined
  3. obj.method?.() —— 如果 obj.method 存在则调用 obj.method(),否则返回 undefined

正如我们所看到的,这些语法形式用起来都很简单直接。?. 检查左边部分是否为 null/undefined,如果不是则继续运算。

?. 链使我们能够安全地访问嵌套属性。

Symbol 类型

“symbol” 值表示唯一的标识符。

可以使用 Symbol() 来创建这种类型的值:

let id = Symbol();

创建时,我们可以给 symbol 一个描述(也称为 symbol 名),这在代码调试时非常有用:

// id 是描述为 "id" 的 symbol
let 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 有两个主要的使用场景:

  1. “隐藏” 对象属性。

    如果我们想要向“属于”另一个脚本或者库的对象添加一个属性,我们可以创建一个 symbol 并使用它作为属性的键。symbol 属性不会出现在 for..in 中,因此它不会意外地被与其他属性一起处理。并且,它不会被直接访问,因为另一个脚本没有我们的 symbol。因此,该属性将受到保护,防止被意外使用或重写。

    因此我们可以使用 symbol 属性“秘密地”将一些东西隐藏到我们需要的对象中,但其他地方看不到它。

    相反,Object.assign 会同时复制字符串和 symbol 属性:

  2. 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。

转换算法是:

  1. 调用 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 -> 1000
    alert(user + 500); // hint: default -> 1500
    
  2. 否则,如果 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
      
  3. 否则,如果 hint 是 "number"或者"default"

    • 尝试调用 obj.valueOf()obj.toString(),无论哪个存在。

所有这些方法都必须返回一个原始值才能工作(如果已定义)。

在实际使用中,通常只实现 obj.toString() 作为字符串转换的“全能”方法就足够了,该方法应该返回对象的“人类可读”表示,用于日志记录或调试。

posted @ 2023-07-19 22:14  Kiiakia  阅读(110)  评论(0编辑  收藏  举报