JavaScript 基础 - 上

795 阅读23分钟

系列文章适合想系统性学习、进阶 JavaScript 或扫盲的前端开发者阅读tesst

什么是 JavaScript

诞生

JavaScript 在诞生之初只是为了解决表单验证的问题。当时,用户们都在使用 28.8 bit/s 的调制解调器上网,随着网页变得越来越大、越来越复杂。这时用户提交一个表单就需要与服务器进行大量的数据交换,在当时那么慢的网络情况下,等了 30s 之后弹出一条消息,告诉你字段 A 为必填项,是不是很令人崩溃。所以当时网景公司就想开发一个客户端脚本语言在浏览器端来处理这种简单的脚本验证工作。

于是,这个事情就交给了 Brendan Eich 来做,用了 2 周时间就给开发了出来,叫 LiveScript,后来为了搭上 Java 的炒作顺风车,将名称更改为 JavaScript。

网景公司发布的 JavaScript 1.0 很成功,这时,微软又冒出来在自己的 IE3 中加入了 JScript。微软的加入就意味着 JavaScript 的实现出现了两个版本,而且当时的 JavaScript 还没有规范其语法或特性的标准,两个版本共存让这个问题更加突出,于是标准化出现了。

JavaScript 1.1 作为提案被提交到 Ecma,其中的 TC39 承担了标准化的工作,花了数月时间打造出 ECMA-262,它是 ECMAScript 的语言标准,自此之后,各家浏览器均以 ECMAScript 作为自己 JavaScript 实现的依据。

JavaScript && ECMAScript

ECMA-262 定义了一门语言的语法、类型、语句、关键字、保留字、操作符、全局对象。ECMAScript (ES) 是基于 ECMA-262 定义的一门(伪)语言,它作为一个基准定义存在,以便在其之上再构建更稳健的脚本语言,比如 以浏览器作为宿主环境的 JavaScript,服务器端的 JavaScript 宿主环境 Node.js。宿主环境提供了 ECMAScript 的基准实现和与环境自身交互所必须的扩展,扩展(比如 DOM)使用 ECMAScript 的核心类型和语法,提供特定于环境的额外功能。完整的 JavaScript 实现包含以下三部分:

  • 核心(ECMAScript)
  • 文档对象模型(DOM)
  • 浏览器对象模型(BOM)

HTML 中的 JavaScript

引入 JavaScript

通过在 HTML 中嵌入 script 标签来引入 JavaScript

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!-- JavaScript 脚本 -->
  <script></script>
</body>
</html>

script 标签可以放在 head 标签内和 body 标签内,但是最佳实践一般是放在 body 标签的最后,这样做可以为页面带来更好的性能和用户体验。

html 是自上而下顺序解析的,如果将 script 标签放在 head 标签内,就意味着必须将所有的 JavaScript 代码都下载、解析和执行完成后,才能开始渲染页面(页面解析到 body 标签时开始渲染)。这会造成页面渲染的明显延迟,给浏览器窗口带来大量的白屏时间。而且如果 JavaScript 中有操作的 DOM 的代码,由于 DOM 还不存在,所以会导致 JavaScript 代码直接报错。

所以,最佳实践将 script 标签放到 body 元素的最后。这样页面在处理 JavaScript 代码之前就完全渲染好了,白屏时间减少,会感觉页面加载速度变快了。

行内代码和外部文件

<!-- 行内代码 -->
<script>
  function example() {
    console.log('行内代码示例')
  }
  example()
</script>
<!-- 外部文件 -->
<script src="./example.js"></script>

在 HTML 中两种引入脚本的方式,分别是行内代码和外部脚本。行内代码是直接将 JavaScript 代码嵌入 HTML 文件,外部文件是将所有的 JavaScript 代码统一编写到单独的 JS 文件中,然后通过 script 标签引入。最佳实践是 外部脚本 的方式,理由如下:

  • 可维护性,JavaScript 如果分散到很多的 HTML 页面,页面会充斥着大量的 JavaScript 代码,会导致维护上的困难。将 JavaScript 放到单独的目录来编写和维护,则更容易,也方便 HTML 和 JavaScript 并行开发。
  • 缓存,HTML 页面一般会采用协商缓存,而 JavaScript 更适合的是强制缓存。浏览器会根据特定的设置缓存所有外部链接的 JavaScript 文件,意味的更快的页面加载。
  • 适应未来,通过把 JavaScript 放到外部文件中,不必考虑用 XHTML 的注释黑科技,包含外部 JavaScript 文件的语法在 HTML 和 XHTML 中是一样的。

异步脚本

异步脚本只针对外部文件才有效,分为推迟执行脚本、异步执行脚本、动态加载脚本

推迟执行脚本 - defer

通过在 script 标签上设置 defer 属性实现,defer 属性表示会异步下载脚本,但会推迟到页面解析完成后(DOMContentLoad事件之前)再顺序执行。但是,在实际中,顺序执行和执行时间点并不能一定保证,所以,页面最后只包含一个还有 defer 属性的 script

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script defer src="./example.js"></script>
</head>
<body>
</body>
</html>

异步执行脚本 - async

在 script 标签上设置 async 属性实现,告诉浏览器异步下载脚本,脚本下载完成后立即执行,执行时会阻塞 HTML 的渲染,所以,async 不能保证脚本的执行顺序,脚本中也不能有在初始化阶段就修改 DOM 的操作。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script async src="./example1.js"></script>
  <script async src="./example2.js"></script>
</head>
<body>
</body>
</html>

上面两个示例将 script 标签放到了 head 标签内,但却不会影响页面性能,脚本下载和页面渲染是可以并行的(异步)。示例只是为了说明现象,别忘了最佳实践。

动态加载脚本

通过 JavaScript 的 DOM API 来实现动态加载脚本。通过向 DOM 中动态添加 script 标签加载指定脚本,方法如下:

const script = document.createElement('script')
script.src = './example.js'
document.head.appendChild(script)

只有当执行到这段 JavaScript 代码时才会动态发送请求取加载 example.js 文件。以这种方式创建的 script 元素是以异步方式加载的,相当于添加了 async 属性。为了更好的性能可以配置预加载。

<link rel="preload" href="./example.js">

<noscript> 元素

针对页面不支持 JavaScript 时的优雅降级方案,当浏览器不支持脚本或对脚本的支持被关闭时,显示 noscript 中的内容

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <noscript>
    <p>
      浏览器不支持 JavaScript 脚本
    </p>
  </noscript>
</body>
</html>

语言基础

语法

标识符区分大小写

// 标识符区分大小写
const t1 = 't1', T1 = 'T1';

标识符指变量、函数、属性、函数参数名。标识符可以由一个或多个下列字符组成:

  • 第一个字符必须是字母、下划线(_)或美元符号($)
  • 剩下的其它字符可以是字母、数字、下划线、美元符号

最佳实践:标识符采用小驼峰命名方式,即第一个单词的首字母小写,后面每个单词的首字母大写,比如:testVariable

注释

// 单行注释
/**
 * 多行注释
 */

严格模式

ECMAScript 增加了严格模式的概念,主要针对 ECMAScript 3 的一些不规范写法在这种模式下会被自动处理,对于不安全的部分会跑出错误。

在文件的最开始加上 use strict 对整个脚本启用严格模式

"use strict";

在函数的最开始加上 use strict 对这个函数启用严格模式

function fn() {
	"use strict"
	console.log('test')
}

"use strict" 是一个预处理指令,任何支持的 JavaScript 引擎看到它都会切换到严格模式。

语句

语句末的分号可有可无,没有解析器会尝试在合适的位置补上分号,所以加分好可以提升性能,不过现在各个打包工具都可以做这个事情,所以加不加无所谓,团队统一编码风格即可。

let test = 'test';
if (test) {
  test = false;
  console.log(test);
}

关键字、保留字

ECMA-262 描述了一组关键字和保留字,这组字不要用作标识符。

  • 关键字:
break       do          in            typeof
case        else        instanceof    var
catch       export      new           void
class       extends     return        while
const       finally     super         with
continue    for         switch        yield
debugger    function    this
default     if          throw
delete      import      try
  • 保留字:
始终保留:

enum


严格模式下保留:

implements  package     public
interface   protected   static
let         private


模块代码中保留:

await

变量

ECMAScript 变量是松散类型,每个变量可以用于保存任意类型的数据。通过三个关键字来声明变量:varconstlet

var 关键字

使用 var 可以声明全局变量和局部变量,全局变量会统一作为 window 对象的属性存在。在函数中使用 var 可以声明一个局部变量,在函数执行结束后就会销毁。在 非严格模式 下声明变量时不加 var 关键字会声明一个全局变量,不推荐这么做。严格模式下会报错

var t1 = 't1';
// 合法但不推荐
t2 = 't2';
function fn() {
  var t3 = 't3';
  // 合法但不推荐
  t4 = 't4'
}

使用 var 关键字声明变量存在 声明提升,JavaScript 编译器会把所有的声明拉到作用域的顶部

function fn() {
  console.log(test)
  var test = 'test'
}
// 以上函数会被编译器编译成
function fn() {
  var test;
  // 输出 undefined
  console.log(test)
  test = 'test'
}

let 关键字

let 和 var 的作用差不多,但有一些非常重要的区别:

  • let 声明的范围是块级作用域,而 var 声明的范围是函数作用域

    // var
    if (true) {
      var t1 = 't1'
      console.log(t1)	// t1
    }
    console.log(t1)	// t1
    
    // let
    if (true) {
      let t2 = 't2'
      console.log(t2)	// t2
    }
    console.log(t2)	// ReferenceError: t2 is not defined
    
  • let 声明的变量不存在声明提升的问题(暂时性死区)

    // var
    console.log(t1)	// undefined
    var t1 = 't1'
    
    // let
    console.log(t2)	// ReferenceError: t2 is not defined
    let t2 = 't2'
    
  • let 在同一个块级作用域内不允许重复声明,var 后面声明的会覆盖前面的

    // var
    var t1 = 't1';
    var t1 = 'tt1';
    console.log(t1)	// tt1
    
    // let
    let t2 = 't2'
    // SyntaxError: Identifier 't2' has already been declared
    let t2 = 'tt2'
    
    // var + let
    var t3 = 't3'
    // SyntaxError: Identifier 't3' has already been declared
    let t3 = 'tt3'
    
  • let 声明的全局变量不会成为 window 对象的属性,var 则会

    // var
    var t1 = 't1'
    console.log(window.t1)	// t1
    
    // let
    let t2 = 't2'
    console.log(window.t2)	// undefined
    
  • for 循环,var 声明的变量会渗透到循环体外部,let 仅限于 for 循环块内部

    // var
    for (var i = 0; i < 5; i++) {
      setTimeout(() => {
        // 2 秒后输出的是渗透到循环外部的变量 i 的值,这时 i = 5
        console.log(i)
      }, 2000)
    }
    console.log(i)	// 5
    // 2秒后输出 5 个 5
    
    // let
    for (let j = 0; j < 5; j++) {
      // JavaScript 引擎会在后台为每个迭代循环声明一个新的迭代变量,所以每个 setTimeout 引用的都是不同的变量实例,所以 console.log 在 2 秒后输出了期望的值,这个行为适用于所有的 for 循环,比如 for-in、for-of
      setTimeout(() => {  
        console.log(j)
      }, 2000)
    }
    console.log(j)	// ReferenceError: j is not defined
    // 2 秒后输出 0、1、2、3、4
    

const 关键字

const 的行为和 let 基本相同,唯一一个重要的区别是用 const 声明变量时必须同时初始化变量,而且声明后不可再次修改变量的值,所以一般用 const 声明常量(不可变的变量)

const t1 = 't1'
// TypeError: Assignment to constant variable
t1 = 't2'

以下操作没问题,因为你没有修改 obj 本身,obj 本身存储的是一个内存地址,你修改的是这个内存地址内的对象

const obj = {
  t: 'tt'
}
obj.t = 'ttt'
obj.tt = 'tttt'

JavaScript 引擎会为 for 循环中的 let 声明分别创建独立的变量实例,虽然 const 变量和 let 变量相似,但是不能用 const 来声明迭代变量,因为迭代变量会自增

// TypeError: Assignment to constant variable.
for (const i = 0; i < 5; i++) {}

最佳实践

const 和 let 的出现解决了 var 的各种怪异问题,所以最佳实践是:不使用 var,const 优先,let 次之。

数据类型

ECMAScript 有 6 种简单数据类型(原始类型):Undefined、Null、Boolean、Number、String、Symbol(符号)。另外还有一种复杂数据类型叫 Object(对象)。Object 是无序名值对的集合。

Undefined 类型

Undefined 类型只有一个值 - undefined,当声明一个变量却没有赋值的时候,这个变量的值默认就是 undefined。

let message;
console.log(message)	// undefined
console.log(message === undefined)	true

Null 类型

Null 类型也只有一个值 - null,逻辑上讲,null 表示一个空对象的指针,这也是给 typeof 传一个 null 会返回 “object” 的原因

const t = null
console.log(typeof t)	// object

为了提高代码的语义化,建议用 null 来初始化空对象,这样别人一看你的代码就知道这个变量的值是一个引用。undefined 是有 null 值派生而来的,因为 ECMA-262 将它们定义为表面上相等。

console.log(undefined == null)	// true

Boolean 类型

Boolean(布尔值) 类型有两个字面值:true 和 false。虽然布尔值只有两个,但使用 Boolean() 转型函数可以将其他任意类型的值转换为布尔值。

const t = 'test'
console.log(Boolean(t))	// true
数据类型转换为 true 的值转换为 false 的值
Undefined不存在Undefined
Null不存在Null
Booleantruefalse
Number非 0 值0、NaN
String非空字符串""空字符串
Symbol任意值不存在
Object非 null 值null

理解以上转换很重要,因为像 if 等流控制语句会自动将其它类型的值转换为布尔值(隐式类型转换)。所以 最佳实践 是采用===(三等)运算符,提高代码可读性,避免隐式类型转换。

Number 类型

Number 类型使用 IEEE 754 格式表示整数和浮点值。不同的数值类型有不同的数值字面量格式:

  • 十进制const t1 = 20, t2 = 30.1

  • 八进制,第一个数字必须是 0,后面是相应的八进制数(0 - 7),如果字面量种包含超过 7 的数值,则忽略前缀 0,后面的数字会被当成十进制数。八进制数在严格模式下无效,会导致 JavaScript 引擎抛出语法错误

    const t1 = 070	// 八进制的 56
    console.log(t1)	// 56
    const t2 = 079	// 无效的八进制数,当成十进制的 79
    console.log(t2)	// 79
    
  • 十六进制,数字的前缀必须是 0x,然后是十六进制数字(0 - 9,A - F [不区分大小写])

    const t1 = 0xA
    console.log(t1)	// 10
    const t2 = 0x1f
    console.log(t2)	// 31
    

浮点值

浮点值必须包含小数点,而且小数点后面至少有一位数字。小数点前面的数字虽然可以省略,但不推荐

const t1 = 1.1, t2 = 0.1, t3 = .2
console.log(t3)	// 0.2

浮点值占用的内存空间是整数的两倍,所以 ECMAScript 总会想方设法的将值转换为整数存储。比如 小数点后面没有数字的情况(1.)、数值本身就是整数,只是小数点后跟着 0(1.0),这两种情况就会被转换为整数

const t1 = 1.
console.log(t1)	// 1
const t2 = 11.0
console.log(t2)	// 11
const t3 = 12.1
console.log(t3)	// 12.1

对于非常大或者非常小的值,可以使用科学记数法来表示,会显的更简洁

const t1 = 3.125e7	// 相当于 3.125 * 10^7
console.log(t1)	// 31250000

浮点值的精度高达 17 位小数,但是在算术计算中却远不如整数精确。比如 0.1 + 0.2 得到的值不是 0.3,而是 0.300 000 000 000 000 04。由于这种微笑的舍入错误,导致很难测试特定的浮点值,所以永远不用测试某个特定的浮点值。这种舍入错误是因为使用了 IEEE 754 数字导致的,并不是 ECMAScript 独有,只要使用了 IEEE 754 格式的语言都有这个问题

console.log(0.1 + 0.2 === 0.3)	// false
console.log(0.15 + 0.15 === 0.3)	// true

值的范围

由于内存的限制,ECMAScript 所能表示的值有一个范围。最小值在 Number.MIN_VALUE(5e-324),最大值在 Number.MAX_VALUE(1.797 693 134 862 315 7e308)。如果某个计算值超出范围,则被自动转换为 正负 Infinity(无穷值),Infinity 值不能再进行进一步的计算,因为它没有可用的数值表示形式。通过 isFinite() 函数可以确定一个值是不是无穷值。使用 Number.NEGATIVE_INFINITY 和 Number.POSITIVE_INFINITY 可以获取正负 Infinity。

NaN

NaN 表示“不是数值(Not a Number)”,用来表示本应该返回数值的操作失败了(而不是直接抛出错误)

console.log(-0 / +0)	// NaN
console.log(0 / 0)	// NaN
console.log(5 / 'test')	// NaN

任何涉及 NaN 的操作始终返回 NaN,比如 NaN / 10;NaN 不等于包括 NaN 在内的任何值。通过 isNaN 函数判断给定参数是否为 NaN,在判断过程中会参数会自动被转换为数值(隐式类型转换)

console.log(NaN === NaN)	// false
console.log(isNaN(NaN))	// true
console.log(isNaN(10))	// false,10 是数值
console.log(isNaN('10'))	// false,'10' 可以被转换为数字 10
console.log(isNaN('blue'))	// true,'blue' 无法被转换为一个有效的数值

数值转换

有三个函数可以将非数值转换为数值:Number、parseInt、parseFloat。Number 是转型函数,可用于任意类型的数据。后两个函数主要用于将字符串转换为数值。

Number 函数的转换规则如下:

  • 布尔值,true 为 1, false 为 0
  • 数值,直接返回
  • null,返回 0
  • undefined,返回 NaN
  • 字符串,应用如下规则
    • 如果字符串中只包含有效的十进制数,则转换为有效的十进制数值,会忽略有效数值最前面的 0
    • 如果字符串中只包含有效的十六进制数,则将其转换为有效的十进制数值
    • 空字符串返回 0
    • 其它情况全部返回 NaN
  • 对象,调用 valueOf 方法,然后按照上述规则转换返回的值;如果转换结果为 NaN,则再对转换结果调用 toString 方法,应用上述规则转换返回的值
  • Symbol,无法转换,会抛出错误,TypeError: Cannot convert a Symbol value to a number
// Boolean
console.log(Number(true))	// 1
console.log(Number(false))	// 0
// Number
console.log(Number(+9))	// 9
console.log(Number(-09)) // -9
// null
console.log(Number(null)) // 0
// undefined
console.log(Number(undefined)) // NaN
// 字符串
console.log(Number('123'))	// 123
console.log(Number('0x1A'))	// 26
console.log(Number(''))	// 0
// 对象
const obj = { t: 'tt' }
console.log(Number(obj))	// NaN
// Symbol
console.log(Number(Symbol(1)))	// TypeError: Cannot convert a Symbol value to a number

虽然在平时的编程中主动用 Number 转换数据类型的情况比较少,但是有很多的隐式类型转换存在,所以还是需要充分理解转型函数。考虑到 Number 的复杂性,通常在需要得到整数的时候可以使用 parseInt 函数,parseInt 函数更专注于字符串是否包含数值模式。parseInt 从第一个非空字符开始转换,如果第一个字符不是有效的十进制、八进制、十六进制,parseInt 立即返回 NaN。如果是有效的字符,则依次检测后续的每个字符,直到字符串结束或者第一个非有效字符截止。

console.log(parseInt(' -1234test'))	// 1234
console.log(parseInt('test124'))	// NaN
console.log(parseInt(''))	// NaN
console.log(parseInt('0xA'))	// 10
console.log(parseInt('10.5'))	// 10
console.log(parseInt('077'))	// 63

parseInt 还支持第二个参数,显示的告诉第一个参数的进制数

console.log(parseInt('10.5', 2))	// 2,第一个参数被当作二进制数解析

parseFloat 函数的工作方式和 parseInt 类似,但是更简单,parseFloat 只能解析十进制数,没有第二个参数,遇到第一个无效的浮点数字符结束

console.log(parseFloat('  -000.1124.2'))	// -0.1124

String 类型

String(字符串)数据类型可以使用双引号("")、单引号('')或反引号(`)表示,这点跟某些语言(比如 PHP)中使用不同引号会改变对字符串的解释方式不同。不过最好在团队中使用同一种风格,比如都用单引号。

不可变性

ECMAScript 中的字符串是不可变的。意思是一旦创建,它们的值就不能被改变了。要修改某个变量中字符串值,必须先销毁原始字符串,然后将新的字符串赋值给该变量,变量指向的内存地址变了。

let lang = 'Java'
lang = lang + 'Script'

这里整个执行过程是这样的,首先分配一个可以容纳 10 字符的空间,然后将 'Java' 和 'Script' 两个字符串填充进去,然后销毁原始的 'Java' 和 'Script' 字符串,将 lang 指向刚分配的空间。所有的处理都是在后台发生的,而这也是一些早起的浏览器在拼接字符串时非常慢的原因。这些浏览器在后来的版本中都针对性的解决了这个问题。

转换字符串

有四种方式可以将一个值转换为字符串,toString 方法、String 转型函数、加号(+)操作符和模版字面量

除了 null 和 undefined 之外的所有数据类型都有 toString 方法,调用 toString 方法可以返回当前值的字符串形式。如果被转换的值是数值类型,还可以给 toString 传递一个参数表示以什么底数(进制数)来输出数值的字符串形式。

var str = '12', num = 12
console.log(str.toString())	// '12'
console.log(str.toString(2))	// '12',参数会被忽略,只有被转换的值为数值时,参数才有效
console.log(num.toString())	// '12'
console.log(num.toString(2))	// '1100'

如果不确定一个值是不是 null 或 undefined,可以使用 String 转型函数,它使用会返回值相应的字符串形式,其转换规则如下:

  • 如果值有 toString 方法,则调用该方法并返回结果
  • 否则返回 null 和 undefined 的字符串形式,'null' 和 'undefined'
console.log(String(10))	// '10'
console.log(String(true))	// 'true'
console.log(String(null))	// 'null'
console.log(String(undefined))	// 'undefined'

一个值加上一个空字符("")也可以将其转换为字符串

console.log(12 + '')	// '12'

通过模字面量的方式也可以将一个值转换为字符串形式

const num = 12
const str = `${num}`
console.log(typeof num)	// number
console.log(typeof str)	// string

模版字面量(模版字符串)

ECMAScript 6 新增了模版字符串, 与使用单引号、双引号定义字符串不同,模版字符串可以保留换行符和空格,在定义模版时很有用。

// 一定要注意格式,将生成的字符串输出或者写入文件格式和你想象的会有一点出入,比如文件的第一行是空的,内容从第二行开始,所以 <div> 应该从反引号之后就开始
const pageHTML = `
<div>
	<a href="#">
		<span>Test</span>
	</a>
</div>
`
console.log(pageHTML)

从技术上讲,模版字符串不是一个字符串,是一个立即求值的 JavaScript 表达式,求值的结果是一个字符串。模版字符串通过 ${} 在字符串中插入任意 JavaScript 表达式,这个表达式会在最近的作用域中取值并调用 toString 方法转换为字符串。

const a = 1, b = 2, c = 3;
const expStr = `加法运算:${a} + ${b} = ${c}`
console.log(expStr)	// '加法运算:1 + 2 = 3'

模版字符串还支持定义标签函数,通过标签函数可以自定义模版字符串的行为。标签函数会接受被插值记号(${exp}) 分割(String.prototype.split)后的模版数组和对每个表达式求值的结果

function tagFunction(templateArray, aExp, bExp, cExp) {
  // 对模版字符串用插值记号(${})分割后的数组
  console.log(templateArray)	// ["", " + ", " = ", ""]
  // 各个表达式的求值结果
  console.log(aExp)	// 1
  console.log(bExp)	// 2
  console.log(cExp)	// 3
  return 'custom result'
}
const a = 1, b = 2, c = 3;
const result = tagFunction`${a} + ${b} = ${c}`
console.log(result)	// custom result

使用 String.raw 标签函数可以获取原始字符串(比如换行符或Unicode字符),而不是被转换后的字符表示

// 输出换行符 \n 本身
console.log(`\n`)	// 会输出换行
console.log(`\\n`)	// 需要使用转译符号才能输出 \n
console.log(String.raw`\n`)	// \n

Symbol 类型

Symbol(符号)类型是 ECMAScript 6 新增的原始数据类型,它具有唯一性和不变性,比如用作对象属性可以保证不会覆盖现有属性。

基本用法

Symbol 没有字面量语法,只能使用 Symbol() 函数来初始化一个 Symbol 值

const sym = Symbol()
console.log(typeof sym)	// symbol
console.log(sym)	// Symbol()
const fooSym = Symbol('foo')
console.log(fooSym)	// Symbol(foo)
const bar1Sym = Symbol('bar'), bar2Sym = Symbol('bar')
console.log(bar1Sym === bar2Sym)	// false,证明不会覆盖现有任何属性
// 作为对象属性
const obj = {}
obj[bar1Sym] = 'test1'
obj[bar2Sym] = 'test2'
console.log(obj)	// {Symbol(bar): "test1", Symbol(bar): "test2"}

全局符号注册表

如果需要在多个地方共享和重用的某个符号实例,可以用一个字符串作为键,在全局符号注册表中通过 Symbol.for(strParam) 创建一个符号来使用。

Symbol.for() 对每个字符串执行幂等操作。使用某个字符串调用 Symbol.for 时,会在全局注册表中检查是否已经存在对应的符号,如果不存在则创建一个新的符号实例并添加到全局注册表中,如果存在则返回已有符号的实例。这点和通过 Symbol 创建的符号不一样。

Symbol.for 的参数必须是字符串,如果传递了非字符串类型,默认会调用 toString 方法转成字符串。

使用 Symbol.keyFor() 方法来查询全局注册表中指定符号的字符串键,如果查询的不是全局符号,则返回 undefined。

const fooGlobalSym = Symbol.for('foo')
const otherFooGlobalSym = Symbol.for('foo')
console.log(fooGlobalSym === otherFooGlobalSym)	// true,证明重用已有符号
const fooSym = Symbol('foo')
console.log(fooSym === fooGlobalSym)	// false,即使采用相同的字符串,Symbol 和 Symbol.for 创建的符号也不相等
console.log(Symbol.keyFor(fooGlobalSym))	// foo
console.log(Symbol.keyFor(fooSym))	// undefined

使用符号作为属性

凡是可以使用字符串或数值作为属性的地方,都可以使用符号。使用符号作为对象属性是最好通过变量保存符号,否则在后面使用 obj[key] 索引时会很困难。

  • Object.getOwnPropertyNames() ,返回对象实例所有的常规属性数组
  • Object.getOwnPropertySymbols(),返回对象实例的所有符号属性数组
  • Object.getOwnPropertyDescriptors(),返回同时包含常规和符号属性的属性描述符对象
  • Reflect.ownKeys(),返回两种类型的键
const obj = { t1: 't1' }
const t2Sym = Symbol('t2')
obj[t2Sym] = 't2Sym'
console.log(obj)	// {t1: "t1", Symbol(t2): "t2Sym"}
console.log(Object.getOwnPropertyNames(obj))	// ['t1']
console.log(Object.getOwnPropertySymbols(obj))	// [Symbol(t2)]
console.log(Object.getOwnPropertyDescriptors(obj))	// 同时包含常规和符号的属性描述符对象
console.log(Reflect.ownKeys(obj))	// ['t1', Symbol(t2)]

常用内置符号

ECMAScript 6 引入了一批常用内置符号,用于暴露语言的内部行为,开发者可以直接访问、重写或着模拟这些行为。这部分的内容放到 JavaScript API 原理实现 来讲。

Object 类型

ECMAScript 中的对象就是一组数据和功能的集合。通过 new Object() 或者对象字面量{} 的形式来创建对象。Object 也是派生其它对象的基类,Object 类型的所有属性和方法在派生对象上同样存在。每个对象实例都有如下属性和方法:

  • constructor: 构造函数,用于创建对象的函数,new Object(),Object 就是这个 constructor 属性的值
  • hasOwnProperty(propertyName): 判断指定对象自身上是否存在指定属性
  • isPrototypeOf(obj): 判断当前对象是否为 obj 的原型对象
  • propertyIsEnumerable(propertyName): 判断给定属性是否可枚举
  • toLocaleString(): 返回对象的字符串表示形式,该字符串和对象所在本地执行环境有关
  • toString(): 返回对象的字符串表示形式
  • valueOf(): 返回对象对应的数值、字符串、boolean值形式。通常与 toString 的返回值相同。
const obj = { a: 'aa' }
console.log(obj.constructor)	// Object 函数
console.log(obj.hasOwnProperty('a'))	// true
console.log(Object.prototype.isPrototypeOf(obj))	// true
console.log(obj.propertyIsEnumerable('a'))	// true
console.log(obj.toLocaleString())	// [object Object]
console.log(obj.toString())	// [object Object]
console.log(obj.valueOf())	// {a: "aa"}

操作符

ECMA-262 描述了一组可用于操作任意类型数值的操作符,包括数学操作符(加、减、乘、除)、位操作符、关系操作符等。

一元操作符

一元操作符包括自增(++)、自减(--)、正数(+)、负数(-)。

自增和自减分为前缀版语法和后缀版语言,区别是发生运算的时间不同,前缀版是在语句求值之前就发生了,后缀版是先对语句求值,再进行自增(自减)运算。

运算过程中如何涉及非数值类型,则先通过 Number 转型函数将数据转换为数值类型再计算。

let age = 10, num1 = 2, num3 = 3;
// 前缀版
console.log(++age)	// 11,先自加再输出
console.log(--age)	// 10,先自减再输出
console.log(++num1 + num3)	// 6
console.log(--num1 + num3)	// 5
// 后缀版
console.log(age++)	// 10,先输出再自加
console.log(age--)	// 11,先输出再自减
console.log(age)	// 10
console.log(num1++ + num3)	// 5
console.log(num1-- + num3)	// 6
console.log(num1)	// 2
// 一元加、减
let test = 25
console.log(+test)	// 25
console.log(-test)	// -25
// 非数值
let str1 = "11", str2 = "a", bVal = false, fVal = 1.1, obj = { valueOf() { return -1 } }
console.log(str1++ + 1)	// 12,先做了类型转换再加和,最后自加
console.log(+str2)	// NaN
console.log(--bVal)	// -1
console.log(++fVal) // 2.1
console.log(--obj)	// -2

位操作符

位操作符用于数值的底层操作,直接操作数据在内存中的比特位。ECMAScript 中的所有数值都以 64 位格式存储,但位操作并不直接操作 64 位的数值,而是先把数值转换为 32 位整数,再进行位操作,之后再把结果转换为 64 位存储,整个转换过程都在后台发生。对于开发者而言,就好像只有 32 位整数。

有符号数使用 32 中的前 31 位表示整数值,第 32 位表示符号位,0 为正 1 为负,在处理有符号整数时无法访问和操作第 32 位。正数以数值真正的二进制格式存储,负数以二进制补码(二补数)的形式存储。一个数值的二补数通过如下三步计算:

  • 确定绝对值的二进制表示,比如 -18,先确定 18 的二进制表示
  • 计算数值的反码(一补数),即每一位 0 变 1,1 变 0
  • 给结果加 1

以 -18 为例,先从 18 的二进制数表示开始:

0000 0000 0000 0000 0000 0000 0001 0010

然后计算反码,即反转每一位

1111 1111 1111 1111 1111 1111 1110 1101

最后给反码加 1 得到 -18 的二进制表示形式

1111 1111 1111 1111 1111 1111 1110 1110

在 ECMAScript 中,把负值输出为一个二进制字符串时,我们会得到一个前面加了负号的绝对值,比如 刚才的 -18 ,输出的结果是 "-10010"。ECMAScript 将上述逻辑全部封装在了内部,将结果以更符合逻辑的形式表示了出来。

const num = -18;
console.log(num.toString(2))	// -10010

ECMAScript 中的所有整数都是有符号数,对于无符号整数来说,第 32 位不表示符号,因为只有正数,所以无符号整数比有符号整数的表示范围更大,因为符号位被用来表示数值了。64 位转 32 位再转会 64 位,导致了一个奇特的副作用,即特殊值 NaN 和 Infinity 在位操作中都会被当作 0 处理。如果将位操作应用到非数值,后台会自动使用 Number 转型函数将值转换为数值,再进行位操作。

按位非、或、非、异或

与、或、非、疑惑只需记住如下口诀即可

  • 与&:都为 1 时才为 1,其它都为 0
  • 或|: 两位都为 0 时返回 0, 其它都为 1
  • 非~: 0 变 1,1 变 0
  • 异或~: 相异为 1,相同为 0
const num = 18, num1 = 3;
console.log(num.toString(2))	// 10010
console.log(num1.toString(2))	// 00011
const and = num & num1
console.log(and, and.toString(2))	// 2, 00010
const or = num | num1
console.log(or, or.toString(2))	// 19, 10011
// 按位非的结果是对数值取反并减1
const not = ~num
console.log(not, not.toString(2)) // -19, -10011
const xor = num ^ num1
console.log(xor, xor.toString(2))	// 17, 10001

左移

左移操作符用两个小于号(<<)表示,按指定的位数将所有操作符向左移动,右边空出来的位补 0 即可。左移会保留其符号位。

// 每左移一位相当于乘以2
console.log(2 << 5)	// 64
console.log(-2 << 5)	// -64

有符号数右移

有符号数右移用两个大于号(>>)表示,会将数值的所有 32 位向右移动指定位数,保留符号位,左侧空出来的位补 0 即可。实际上有符号右移相当于左移的逆运算。

// 每右移一位相当于除以2
console.log(64 >> 5)	// 2
console.log(-64 >> 5)	// -2

无符号数右移

无符号右移用单个大于号(>>>)表示,将包括符号位在内的 32 位都向右移。对于正数,无符号右移和有符号右移结果相同,因为符号位是 0, 右移时左侧补 0,没区别。对于负数,有时候会差异很大,毕竟负数的符号位是 1。

布尔操作符

布尔操作符一共有 3 个:逻辑非、逻辑与、逻辑或。

逻辑非

逻辑非操作符用叹号(!)表示,可用于任意值,它始终返回布尔值。逻辑非会先将操作数转换为布尔值,再对其取反。转换规则如下:

  • 如果操作数是对象、非空字符串、非 0 数值(包括 Infinity)则返回 false
  • 如果操作数是null、undefined、NaN则返回 true
// false
console.log(!true)
console.log(!{})
console.log(!'test')
console.log(!10)
console.log(!Infinity)
// true
console.log(!null)
console.log(!undefined)
console.log(!NaN)

同时使用两个叹号(!!)也可以把任意值转换为布尔值,相当于调用 Boolean() 转型函数。其中第一个叹号总会返回取反的布尔值,第二个叹号再次取反则返回变量真正的布尔值。

逻辑非

逻辑与用两个和号(&&)表示。是一种短路操作符,如果第一个操作数决定了结果永远不会对第二个操作数求值。只有两个值同为真时返回真。

console.log(true && false)	// false

逻辑与操作符可用于任意操作数,不限于布尔值。如果有操作数不是布尔值,结果不一定是布尔值,则遵循如下规则:

  • 如果第一个操作数为真,则返回第二个操作数
  • 第一个操作数是null、undefined、NaN,则返回第一个操作数
console.log(true && {})	// {}
console.log({} && 'test')	// test
console.log(null && 'test') // null

逻辑或

逻辑或操作符用两个管道符(||)表示。如果有操作数为真,逻辑或操作符遵循:如果有操作数为真则结果为真。与逻辑与类似,如果有一个操作数不为布尔值,则结果不一定会返回布尔值,规则为:第一个操作数为真,则返回第一个操作符,否则返回第二个操作数。

console.log(true || 'test') // true
console.log({} || false)	// {}
console.log(false || {})	// {}

乘性操作符

ECMAScript 定义了 3 个乘性操作符:乘法、除法、取模。这些操作跟他们在 Java、C语言中对应的操作一样,就是正常的数学运算,但在 ECMAScript 中,不一样的地方在于处理非数值时,会包含一些自动类型转换,这个转换过程是在后台调用 Number() 转型函数自动完成的。

乘法运算符

乘法运算符用一个星号(*)表示,用于计算两个数的乘积,规则如下:

  • 两个操作数都是数值,则执行正常的数学运算,结果超出表示范围则返回 Infinity 或 -Infinity
  • 如果有操作数是 NaN 或者 Infinity * 0,返回 NaN
  • Infinity * 非 0 值,根据第二个操作数的符号返回 Infinity 或 -Infinity
  • Infinity * Infinity,返回 Infinity
  • 如果有操作数不是数值,则现在后台调用 Number() 将其转换为数值,再应用上述规则

除法运算符

除法运算符用一个斜杠(/)表示,用于计算两个操作数的商,规则如下:

  • 两个操作数都是数值,则执行正常的数学运算,结果超出表示范围则返回 Infinity 或 -Infinity
  • 如果有操作符是 NaN 或 Infinity / Infinity 或 0 / 0,则返回 NaN
  • 非 0 有限值除以 0,则根据第一个操作数的符号返回 Infinity 或 -Infinity
  • Infinity / 任何数值,则根据第二个操作数返回 Infinity 或 -Infinity
  • 如果有操作数不是数值,则先在后台调用 Nunber() 转型函数将其转换为数值,在应用上述规则

取模操作符

取模(求余[数])操作符由一个百分符号(%)表示,比如:

console.log(26 % 5)	// 1

和其它乘性操作符一样,取模操作符对特殊值也有一些特殊的行为:

  • 如果两个操作数都是数值,则执行正常的求余操作
  • Infinity % 有限值、有限值 % 0、Infinity % Infinity,返回 NaN
  • 有限值(1) % Infinity,返回有限值(1)
  • 0 % 任意数值,返回 0
  • 如果有操作数不是数值,则先在后台调用 Number 转型函数将其转换为数值,在应用上述规则

指数操作符

ECMAScript 7 新增了指数操作符,用两个星号(**)表示,之前用 Math.pow() 执行指数运算

console.log(Math.pow(3, 2))	// 9
console.log(3**2)	// 9
let ret = 3
console.log(ret **= 2)	// 9

加性操作符

加性操作符,即加法和减法操作符,本是最简单的操作符。但是在 ECMAScript 中,这两个操作符拥有一些特殊的行为。和乘性操作符类似,加性操走符会在后台发生不同数据类型的转换。只不过转换规则不是那么直观。

加法操作符

  • 两个操作数都是数值
    • 任意值 + 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'。

减法操作符

  • 两个操作数都是数值
    • 任一操作数是 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() 方法,然后将得到的字符串再转换为数值,然后应用上述规则。

关系操作符

关系操作符用于比较两个值,包括 <、>、<=、>=,用法和数学一样,返回布尔值。与 ECMAScript 其它操作符一样,将它们应用到不同数据类型时也会发生类型转换和其它它行为。

  • 两个操作数都是数值,正常比较即可
  • 如果操作数都是字符串,则逐个比较字符串中对应字符的编码,比如 'a' > 'A'
  • 如果有任一操作数是数值,则将另一个操作数转换为数值,执行数值比较
  • 如果有任一操作数是对象,则调用对象的 valueOf() 方法,如果没有 valueOf(),则调用 toString() 方法,取得结果再根据前面的规则比较
console.log(23 > 3)	// true
console.log('23' > 3) // true
console.log('23' > '3') // false
// 特殊情况,任何关系操作符涉及比较 NaN 时都返回 false
console.log(NaN < 3)	// false
console.log(NaN >= 3)	// false

相等操作符

ECMAScript 中提供了两组判等操作符,第一组是等于和不等于(==、!=),它们在执行比较前会执行强制类型转换,这种方式被大家所诟病,就有了后来的第二组,全等和全不等(===、!==),它们在比较前不执行类型转换,类型不同,直接认为是不等。

等于和不等于

在执行比较时,遵循如下规则:

  • 如果任一操作数是布尔值,则将其转换为数值再进行比较。false 转换为 0,true 转换为 1
  • 如果一个操作数是字符串,一个是数值则将字符串转换为数值,再比较
  • 如果一个操作数是对象,另一个不是,则调用对象的的 valueOf 方法取得原始值,再根据前面的规则比较
  • 如果两个操作数都是对象,则比较它们是否指向同一个对象,如果是则认为相等,返回 true,否则返回 false
  • null 和 undefined 相等,并且 null 和 undefined 不能转换为其它类型的值再进行比较
  • 如果任一操作符是 NaN,相等操作符返回 false ,不等操作符返回 ture。但是 NaN == NaN,返回 false
console.log(null == undefined)	// true
console.log("NaN" == NaN)	// false
console.log(5 == NaN)	// false
console.log(NaN == NaN)	// false
console.log(NaN != NaN) // true
console.log(false == 0) // true
console.log(true == 1)	// true
console.log(true == 2) // false
console.log(undefined == 0) // false
console.log(null == 0)	// false
console.log("5" == 5)	// true

全等和不全等

相较于等于和不等于,全等(===)和全不等(!==)比较时不需要做类型转换,如果两个操作数类型不同,则直接认为不相等。全等和全不等是一种最佳实践。

// 不同数据类型,所以不相等
console.log('55' === 55) // false
console.log(null === undefined)	// false

条件操作符

条件操作符又叫三元表达式,简单的 if-else 完全可以用更简洁条件操作符代替。

// const variable = boolean_expression ? true_value : false_value;
const num1 = 1, num2 = 2;
const max = num1 > num2 ? num1 : num2;
console.log(max)	// 2

赋值操作符

赋值操作符分为简单赋值(=)和复合赋值(乘性、加性、位操作符后跟等号)。复合操作符只是种简写,不会提升性能。

let num = 10;
// 简单赋值
num = num + 10;
// 复合赋值
num += 10;
num -= 10;
num *= 10;
num /= 10;
num %= 10;
num <<= 2;
num >>= 2;
num >>>= 2;

逗号操作符

逗号操作符一般用于变量声名语句或者函数参数中,比如:

const num1 = 1, num2 = 2;
const test = (5, 3, 4, 0)	// test 的值为 0
function fn(param1, param2) {
  console.log('fn')
}

语句

ECMA-262 描述了一些语句,这些语句用称为流控制语句,而 ECMAScript 中大部分语法都体现在语句中。语句通常使用一或多个关键字完成既定的任务。语句可以简单,也可以复杂。简单的如告诉函数或进程退出,负责的如列出一堆要重复执行的指令。

  • if

  • do-while

  • while

  • for

  • for-in,枚举对象的非符号键,包括圆形对象上的,一般配合 hasOwnProperty 方法一起使用

  • for-of,遍历可迭代对象的元素(值)

  • for-await-for,创建一个循环,该循环可以便利异步可迭代对象和同步可迭代对象。和await 运算符一样,该语句只能用在 async function 内部使用

    /**
     * variable 为每次迭代的结果值,如果迭代对象 iterable 包含的是异步变量,则 variable 为 Promise 的值,否则是值本身
    for await (variable of iterable) {
      statement
    }
    */
    async function* asyncGenerator() {
      var i = 0;
      while (i < 3) {
        yield i++;
      }
    }
    
    (async function() {
      for await (num of asyncGenerator()) {
        console.log(num);
      }
    })();
    // 0
    // 1
    // 2
    
  • 标签语句,标签语句用于给语句加标签,比如 label: statement。标签通过 break、continue 语句饮用。标签语句的典型应用场景是嵌套循环。

    // start 是一个标签
    start: for (let i = 0; i < 10; i++) {
      for (let j = 0; j < 10; j++) {
        if (i === 5 && j === 5) {
          // continue 会跳到外层循环
        	continue start;
          // break 会跳出外层循环
          // break start;
      	}
      	console.log(i)
      }   
    }
    
  • break 和 continue 语句,用于循环语句,break 表示跳出所在循环,continue 表示跳过本次循环

    for (let i = 0; i < 10; i++) {
      for (let j = 0; j < 10; j++) {
        if (i === 5 && j === 5) {
          // 跳过本次循环
          continue;
          // 跳出内层循环
          // break;
        }
        console.log(i)
      }
    }
    
  • with 语句,用途是将代码的作用域设置为特定对象,主要使用场景是针对一个对象反复操作,这时候将代码的作用域设置为该对象能提供便利

    // 不使用 with
    const qs = location.search.substring(1);
    const hostName = location.hostname;
    console.log(qs, hostName)
    
    // with
    with(location) {
      const qs = search.substring(1)
      const hostName = hostname
      console.log(qs, hostName)
    }
    

    虽然 with 能带来一些便利,但是它的性能很差,所以严格模式禁用,也不推荐大家使用 with 语句。其性能差的原因是因为其在运行时会延长作用域链,导致编译器无法在静态编译时对其做性能优化,所以编译器在碰到 with 语句时会跳过所有的性能优化。with 性能为什么差?

  • switch 语句,switch 语句在比较每个条件时会使用全等操作符

函数

函数对于任何语言来说都是核心组件,因为它们可以封装语句,组合逻辑,然后在任何地方、任何时间执行。ECMAScript 中函数使用 function 关键字声名,后面跟一组参数,然后是函数体

function fn(arg0, arg1, ...args) {
  console.log(arg0, arg1, ...args)
}
fn(1, 2, 3, 4, 5)	// 1 2 3 4 5

最佳实践中提出,函数要不有返回值,要不没有返回值。只在某种条件下返回值的函数会带来一些意料之外的麻烦。没有指定 return 语句的函数默认返回 undefined

严格模式下函数不能以 eval 或 arguments 作为名称,函数的参数也是,另外函数的参数不能重名。