三刷红宝书之 JavaScript 的引用类型

8,356 阅读27分钟

前言

正如标题所说,这是我第三次翻开红宝书也就是《 JavaScript 高级程序设计第三版》,不得不说,虽然书有一些年份,很多知识点也不适合现代的前端开发,但是对于想要掌握 JavaScript 基础的前端新手,亦或是像我一样想找回曾经遗忘在记忆角落的那些碎片知识,这本书依旧非常的适合,不愧被成为 "JavaScript 圣经"

本文是读书笔记,之所以又一次选择读这本书还有一个理由,之前都是记的纸质笔记,这次想把它作为电子版,也算是对之前知识的整理

本文篇幅较长,目的是作为我的电子版学习笔记,我会尽可能去其糟粕,取其精华,同时我会添加一些书上未记载但很重要的知识点补充

上篇在这里

引用类型

引用类型是一种数据结构,它在别的语言中被称为类,但是在 JavaScript 中实际上并没有类,虽然提供了很多类似“类”的语法(class 关键字),但很多书籍都认为这不是一个好的事情

let date = new Date()
console.dir(date)

这里通过 new 关键字来生成一个 Date 引用类型的实例,可以发现它的原型上有很多公有的方法,引用类型的实例也被成为引用类型的值,是一个对象

ECMAScript 原生的引用类型有

  • Object
  • Array
  • Date
  • RegExp
  • Function
  • Error

接下来我会介绍几个比较常用的引用类型来详细分析

Object

Object 是最常见的引用类型,原生的引用类型都继承自 Object 类型,可以通过点表示法和方括号表示法来访问对象,前者书写更加简单,后者可以支持一些特殊语法和表达式,但是书写比较复杂(同时由于要解析表达式,性能也稍慢)

let obj = {} // 通过字面量表示法来创建对象,比 new 更加简便

obj.a = 1
obj["b"] = 2
obj["c d"] = 3 // 方括号可以支持点表示法不支持的语法

// 方括号也可以支持表达式
// ps:对象中含有数字的属性会被转为字符串
obj[1 + 2] = 4
obj[obj] = 5 // 如果属性是一个对象,会转为原始类型
console.log(obj) // {"3": 4, "a": 1, "b": 2, "c d": 3, "[object Object]": 5}

可以使用 new 关键字来动态生成一个对象,另外它不仅限于普通的对象,还可以生成包装类型的对象

let str = "abc"
let wrappedStr = new Object("abc") // 等同于 new String("abc")

console.log(typeof str) // 'string'
console.log(typeof wrappedStr) // 'object'
console.log(wrappedStr instanceof String) // true 注意 String 首字母是大写,代表是 String 包装类型而非 string 基本类型

wrappedStr 这个包装对象看上去和基本类型相似,但它是一个对象,进一步说是 String 包装类型的实例,这个我们放到之后讲

Array

Array 类型可以通过 new 关键字调用或者字面量表达式来生成实例,当使用 new 并传入一个参数时,会创建参数长度的稀疏数组(由空单元组成的数组),当传入两个以上参数时,会创建由参数组成的数组

let arr1 = new Array(10)
let arr2 = new Array(10,20)

console.log(arr1) //[ <10 empty items> ]
console.log(arr2) // [ 10, 20 ]

数组有一个 length 的内部属性,它是可以被修改的,可以利用这个特点快速清空数组和增加数组长度

let arr = [1,2,3] // 使用字面量创建数组

arr.length = 0
console.log(arr) // []

arr.length = 100
console.log(arr) // [ <100 empty items> ] 创建的都是空单元的稀疏数组,不推荐直接使用

当尝试将 Array 类型转换为字符串时(这可能会存在于隐式转换中),默认会调用数组原型上的 toString 方法,它会依次调用数组中每个元素的 toString 方法 (null 和 undefined 是例外,它们会直接转为空字符串)

let arr = [{a:1},123,()=>{},undefined]
console.log(arr.toString()) // "[object Object],123,()=>{}," 最后以逗号结尾,因为 undefined 变成空字符串了

let obj = {}
obj[arr] = ""
console.log(obj) // { '[object Object],123,()=>{},': "" } 当对象的属性是一个对象,JS 会将其转为字符串再作为其属性,这里就发生了隐式转换

栈和队列

JavaScript 中的数组既可以表示栈也可以表示队列,当使用 pop 方法时会视为栈,将栈顶也就是数组最后一个元素弹出,当使用 shift 方法时会视为队列,将队首也就是数组第一个元素出列

而添加数组元素的方法 push,unshift 后会返回数组的长度

let arr = [1,2,3]

console.log(arr.push(4)) // 4 表示数组长度为4
console.log(arr.unshift(0)) // 5 表示数组长度为5
console.log(arr.pop()) // 4 返回最后一个元素4
console.log(arr.shift()) // 0 返回第一个元素0

sort

通过调用 sort 方法可以给数组元素进行排序,但是这个方法有点特殊,sort 会调用每个数组元素的 toString 方法,这会导致排序的结果和预期的有出入

let arr = [1,2,10,20,100,200]

console.log(arr.sort()) // [ 1, 10, 100, 2, 20, 200 ]

调用 sort 会将每个元素都转为字符串,最终会给 "1", "2", "10", "20""10", "20", "100", "200" 排序,而在上一章中我介绍到,JavaScript 中两个字符串比较,会逐个比较每个字符的字符串的编码,如果当前字符编码相同,则依次比较下一位,一旦某个字符大于另一个字符则直接返回结果,不会再往后比较

这里之所以 10 排在 2 的前面,是因为在比较 "10" 和 "2" 时,先比较第一位也就是字符串 1 和字符串 2,因为字符串 2 的编码大于字符串 1,所以就会直接退出比较,就会产生 "10" < "2" 的结果

这并不是一个奇怪的 BUG,而是 sort 默认使用字典序进行排序,维基百科中是这么解释字典序的

设想一本英语字典里的单词,哪个在前哪个在后?

显然的做法是先按照第一个字母、以 a、b、c……z 的顺序排列;如果第一个字母一样,那么比较第二个、第三个乃至后面的字母。如果比到最后两个单词不一样长(比如,sigh 和 sight),那么把短者排在前。

通过这种方法,我们可以给本来不相关的单词强行规定出一个顺序。“单词”可以看作是“字母”的字符串,而把这一点推而广之就可以认为是给对应位置元素所属集合分别相同的各个有序多元组规定顺序:下面用形式化的语言说明。

如果不用默认的排序方式,可以通过给 sort 方法传入一个比较函数

let arr = [1,2,10,20,100,200]

console.log(arr.sort((a,b) => a - b)) // [ 1, 2, 10, 20, 100, 200 ]

sort 是一个高阶函数,即支持传入一个函数作为参数,每次比较时都会调用传入的参数,其中参数 a 和 b 就是两个准备进行比较的字符串,在上一章还提到过,如果两个字符串相减会转为 Number 类型再进行计算,这样就可以避免字符串类型比较造成的问题

同时如果传入的函数返回值大于 0 ,则 b 会排在 a 前面,小于 0 则 a 会排在 b 前面,等于 0 则不变,根据这个特点可以控制返回数组是顺序还是倒序

let arr = [1,2,10,20,100,200]

// [ 200, 100, 20, 10, 2, 1 ] 倒序数组
// 第一次比较时 b 为 2,a 为 1,由于返回值大于 0,所以 b 将排在 a 的前面,变成 [2,1],以此类推
// 注意是插入排序
console.log(arr.sort((a,b) => b - a))  

sort 它会修改原数组,而不是新生成一个数组作为返回值,使用前请考虑清楚是否需要对原数组进行拷贝

let arr = [1,2,10,20,100,200]

console.log(arr.sort((a,b) => b - a)) // [ 200, 100, 20, 10, 2, 1 ]
console.log(arr) // [ 200, 100, 20, 10, 2, 1 ] 原数组被修改了!

题外话:上述作为参数的函数中,可以通过 Math.random 随机返回大于小于 0 的数字可以实现数组乱序,但是并不是真正的乱序,而洗牌算法可以解决这个问题

concat

concat 会将参数添加到数组末尾,不像 sort 会修改原数组,它会创建一个当前数组的副本(浅拷贝),所以相对比较安全

另外如果参数包含数组,会给数组进行一层降维

let arr = [1, 2, 3]

console.log(arr.concat(4, [5, 6], [7, [8, 9]])) // [ 1, 2, 3, 4, 5, 6, 7, [ 8, 9 ] ]
console.log(arr) // [1,2,3]

当然现在更推荐使用 ES6 的扩展运算符,写法更加简洁,和 concat 实现的功能类似,同样也会浅拷贝数组

let arr = [1, 2, 3]

console.log([...arr, 4, ...[5, 6], ...[7, [8, 9]]]) // [ 1, 2, 3, 4, 5, 6, 7, [ 8, 9 ] ]
console.log(arr) // [1,2,3]

slice

slice 会基于参数来切割数组,它同样会浅拷贝数组,当传入不同参数会有不同功能

  • 不传参数会直接返回一个浅拷贝后的数组
  • 只有第一个参数时,会返回第一个参数的下标到数组最后的数组,参数超过最大下标则返回空数组
  • 当传入两个参数时,会返回第一个参数至第二个参数 - 1 下标的数组
  • 如果第二个参数小于第一个参数(非负数),返回空数组
  • 参数含有负数则会加上数组长度再应用上述规则

slice 方法可以将类数组转为真正的数组

let arr = [1, 2, 3, 4, 5]

console.log(arr.slice()) // [1, 2, 3, 4, 5] 浅拷贝原数组
console.log(arr.slice(2)) //  [3, 4, 5]
console.log(arr.slice(2, 3)) // [3]
console.log(arr.slice(2, 1)) // []
console.log(arr.slice(2, -1)) // [3, 4] 等同于 arr.slice(2, -1 + 5)
console.log(Array.prototype.slice.call({0: "a", length: 1})) // ["a"]

splice

splice 可以认为是 push, pop, unshift, shift 的结合,并且能够指定插入/删除的位置,非常强大,但它传入但参数也更为复杂,所以一般只有在操作数组具体下标元素的时候才会使用,同时它也会修改原数组,使用时请注意

同时 splice 会返回一个数组,如果是使用它的删除功能,则返回的数组中会包含被删除的元素,来看一些比较特殊的例子

let arr = [1, 2, 3, 4, 5]

console.log(arr.splice(0, 1)) // [1]
console.log(arr) // [2, 3, 4, 5]
console.log(arr.splice(1)) // [3,4,5]
console.log(arr) // [2]

第一次调用 splice 会在数组下标为 0 的位置删除一个元素,它的返回值就是被删除的元素 1,同时打印数组,会发现 splice 修改了原来的数组,原数组的第一个元素被删除了

第二次调用 splice 只传了一个参数,表示删除从数组下标为 1 的位置至数组最后一个元素,因为此时数组为 [2,3,4,5],所以删除下标从 1 到最后的元素 3,4,5,并作为 splice 的返回值,最后原数组就只包含元素 2 了

indexOf

indexOf 方法会返回参数在数组中的下标,不存在则返回 -1,一个特殊情况就是 NaN,如果使用 indexOf 判断 NaN 是否在数组中,永远会返回 -1

let arr = [1,2,3,4,5,NaN]

console.log(NaN) // -1

解决这个问题可以使用 ES6 的 includes 方法,它会返回一个布尔值,而非目标元素下标,同时它可以判断 NaN 是否存在与目标数组中

let arr = [1,2,3,4,5,NaN]

console.log(arr.indexOf(NaN)) // -1
console.log(arr.includes(NaN)) // true

我个人习惯使用 includes 这个 api,因为日常中很多情况只需要知道参数是否存在于数组中,所以返回布尔值就足够了,如果遇到需要返回下标,或者从数组的指定位置搜索参数可以使用 indexOf

reverse

reverse 和 sort 以及 splice 一样会修改原数组

let arr = [1,2,3,4,5]

console.log(arr.reverse()) // [5,4,3,2,1]
console.log(arr) // [5,4,3,2,1]

迭代方法

ES5 为数组提供了 5 个迭代方法,它们都是高阶函数,第一个参数是一个函数,第二个参数是函数的 this 指向

  • every
  • filter
  • forEach
  • map
  • some

这些 api 日常用的非常多就不赘述了,只说一个小细节

对一个空数组无论参数中的函数返回什么,调用 some 都会返回 false, 调用 every 都会返回 true

let arr = []

console.log(arr.some(()=>{})) // false
console.log(arr.every(()=>{})) // true

reduce

reduce 也就是归并方法,个人认为是数组中最高级的使用方法,用的好可以实现一些非常强大的功能,这里举个的例子:多维数组扁平化

const flat = function (depth = 1) {
    let arr = Array.prototype.slice.call(this)
    if(depth === 0 ) return arr
    return arr.reduce((pre, cur) => {
        if (Array.isArray(cur)) {
            // 需要用 call 绑定 this 值,否则会指向 window
            return [...pre, ...flat.call(cur,depth-1)]
        } else {
            return [...pre, cur]
        }
    }, [])
}

关于 reduce 还有一个关于下标的注意点,当 reduce 只传一个参数时,index 的下标是从 1 也就是数组第二个元素开始的(如果此时数组为空会报错),当 reduce 传入第二个参数,会作为遍历的起始值,此时 index 的下标就从 0 也就是数组第一个元素开始

let arr = ["b", "c", "d", "e"]

arr.reduce((pre, cur, index) => {
    console.log(index)
    return pre + cur
})

// 1
// 2
// 3

arr.reduce((pre, cur, index) => {
    console.log(index)
    return pre + cur
}, "a")

// 0
// 1
// 2
// 3

RegExp

正则表达式可以使用构造函数动态生成,也可以使用字面量快速生成,由于正则表达式实例也是对象,所以也会有属性,常用的属性有

  • global: 是否设置了 g 标志,即开启全局匹配
  • ignoreCase: 是否设置了 i 标志,即忽略大小写
  • dotAll (ES9) : 是否设置了 s (并非 d )标志,即使用 . 可以匹配任何单个字符,可以理解为 [\s\S](默认 . 不会匹配换行符,回车符,分隔符等)
  • lastIndex: 开始搜索下一个匹配项的字符位置,从 0 算起
  • source: 当前正则表达式的字符串表示
let reg = /\[abc]/ig
console.dir(reg)

let reg2 = new RegExp('\\[abc]', "ig") // RegExp 第二个参数为正则标志
console.dir(reg2)

console.log(reg.test("[abc]"), reg2.test("[abc]"))

打印结果如下

reg 和 reg2 实现的功能是相同的,第一种更简便,第二种更灵活,需要结合实际情况灵活使用

另外还有一个比较重要的点,可以看到 2 个正则对象的 lastIndex 都为 5,此时如果继续用 test 方法匹配会返回 false

let reg = /\[abc]/ig
console.dir(reg)

let reg2 = new RegExp('\\[abc]', "ig") // RegExp 第二个参数为正则标志
console.dir(reg2)

console.log(reg.test("[abc]"), reg2.test("[abc]"))
console.log(reg.test("[abc]"), reg2.test("[abc]"))

之所以出现这样的情况是因为正则对象内部保存的 lastIndex 属性会决定下次正则匹配的位置,第一次用 test 方法匹配成功后,lastIndex 从 0 变成 5,同时下次会从参数的第 6 个元素开始匹配,此时发现匹配不到任何元素,所以会返回 false ,并将 lastIndex 重置为默认值 0

关于正则其实非常复杂,深入会涉及到状态机,回溯,贪婪匹配等知识点,写的不好会影响系统的性能,但是如果能搞懂其中的奥秘,写出优质的正则,能够很大程度上解放劳动力,例如给整个项目替换部分代码,另外 Vue 的模版字符串编译也是依赖正则的匹配来提取属性,自定义指令等

Function

函数也是对象,而函数的函数名只是作为一个指向函数的指针,这个我在上一章也提到过,JavaScript 不允许直接操作对象的内存空间,开发中操作的都是指向堆内存对象的指针

可以通过 new 关键字来动态生成一个函数

let func = new Function("a","console.log(a)")

console.log(func(123)) // 123

Function 函数作为构造函数时,至少接受一个参数,当只传入一个参数时,会直接将参数作为函数体,当传入超过一个函数时,会将最后一个参数作为函数体,之前的所有参数会作为生成的函数的参数

通过 new 动态生成函数优点在于更加灵活,例如 Vue 的编译器最终会将字符串通过 new Function 传入 code 代码并执行

// src/compiler/to-function.js:11
function createFunction (code, errors) {
  try {
    return new Function(code)
  } catch (err) {
    errors.push({ err, code })
    return noop
  }
}

而一般情况我们是不需要使用这种方式的,直接使用字面量的形式创建函数,因为前者虽然更加灵活,但是性能并不是非常理想,同时还有可能存在安全隐患(类似 eval,可能会被恶意用户注入恶意代码运行,形成 XSS 攻击)

重载

JavaScript 中的函数没有重载,但是可以实现一定程度上的参数重载

import $ from 'jquery'

$("p").css("background-color"); // color
$("p").css("background-color",'red');

这里是一个 jquery 的代码,当调用 css 方法传入一个参数时,会返回 p 节点当前的背景颜色,如果传入 2 个参数,会设置背景颜色,css 方法的功能取决于传入参数的个数,原理是利用函数参数个数来判断返回属性还是设置属性

函数声明,函数表达式

当 JavaScript 在加载数据时(也可以理解为进入一个新的环境或者说上下文),会优先读取环境中的所有函数声明,并且这是在加载代码之前执行的操作,而当所有准备工作完成后,才会逐行执行代码,也就是说当代码执行到函数表达式时,函数才会被真正执行

func()

// 函数表达式
const func2 = function () {
    console.log(2)
}

func2()

// 函数声明
function func() {
    console.log(1)
}

执行顺序:func 声明 -> func 执行 -> func2 声明 -> func2 执行

在进入全局环境之前,func 由于"函数声明提升"被提升到最前面执行,随后再是执行整个代码,所以上面代码并不会报错,fun2 是函数表达式,如果将 func2 执行的代码放到 func2 声明之前,就会发生错误,因为函数表达式不会被提升

关于函数声明和函数表达式还有一些小细节:

  • 如果在全局环境中使用函数声明的形式创建函数,那么它会被当作全局函数
  • 函数表达式可以理解为创建一个匿名函数,然后将匿名函数赋值给声明的变量
  • 可以同时使用函数声明和函数表达式
var sum = function sum() {
    //...
    sum() // 在函数内部 sum 指向的是右边的词法名称,而非左边的 sum 变量
}

右边的 sum 会作为匿名函数的“词法名称”,这种情况常用于自身的递归,如果没有这个词法名称函数只能使用 arguments.callee 方法来实现递归(无法使用左边的 sum 变量),而函数内部的 arguments 对象由于性能问题已不推荐使用,所以如果有递归的需求,推荐给匿名函数添加一个词法名称,另外需要注意的是,词法名称是常量,函数内部无法修改

每日一题中就有该知识点的考察,以下是某个解题者的解释

 var b = 10;
(function b() {
   // 内部作用域,会先去查找是有已有变量b的声明,有就直接赋值20,确实有了呀。发现了具名函数 function b(){},拿此b做赋值;
   // IIFE的函数无法进行赋值(内部机制,类似const定义的常量),所以无效。
  // (这里说的“内部机制”,想搞清楚,需要去查阅一些资料,弄明白IIFE在JS引擎的工作方式,堆栈存储IIFE的方式等)
    b = 20;
    console.log(b); // [Function b]
    console.log(window.b); // 10,不是20
})();

length

函数还有一个 length 属性,它表示的是函数希望接受的形参个数

形参的数量不包括剩余参数个数,仅包括第一个具有默认值之前的参数个数

function func(a,b,...c) {}

console.log(func.length) // 2

function func2(a = 1,b,c) {}

console.log(func2.length) // 0

function func2(a,b = 2,c) {}

console.log(func2.length) // 1

可以看到第一个函数的 length 属性为 2,因为后面是剩余参数,不计算在 length 长度中,而第二个因为参数 a 含有默认值,所以会返回 a 之前的参数,同时因为 a 之前没有参数所以最终返回 0,第三个例子中参数 b 有默认值,所以返回 b 之前的参数个数,也就是 1

apply / call

函数在运行时还会生成一个 this 对象,它可以理解为指针,在一般情况下,this 指向的是调用该函数的对象,而使用 函数 apply / call 方法可以改变 this 的指向(再次强调,因为函数也是对象,所以也会有属性和方法)

function func(a,b,c) {
    console.log(a,b,c)
    console.log(this)
}

func.call({a:1},1,2,3) // 1,2,3 {a:1}
func.call('123',1,2,3) // String{"123"} {a:1}
func.apply({a:1},[1,2,3]) // 1,2,3 {a:1}

apply 和 call 的区别在于 apply 的第一个参数为即将执行的函数的 this 值,第二个参数为数组或者类数组,代表即将执行的函数的参数,而 call 第一个参数相同,第二个至最后的参数代表即将执行的参数

即 apply 会用数组保存函数的参数,call 则会平铺,除此以外没有区别(虽然 call 方法必须要将函数参数平铺,但是可以使用 ES6 的扩展运算符将其写为数组的形式,现在更推荐使用 call )

另外 apply 和 call 都会让第一个参数进行装箱操作,即如果传入一个基本类型且基本类型有包装类型(下文会详细解释包装类型),则 this 的值为传入的基本类型的包装类型,例子中将 string 类型变成了 String 的包装类型

非严格模式下,如果 this 的值为 null / undefined,则自动会指向全局的 window 对象,而严格模式则不会有这个行为(值得注意的是这个并非 apply / call 的行为)

function func() {
    console.log(this)
}

func.call(undefined) // window 对象


function func2() {
    "use strict"
    console.log(this)
}

func2.call(undefined) // undefined

bind

bind 和 apply / call 方法类似,也是一个用来改变函数 this 指向的方法,区别在于 bind 会返回一个被绑定 this 指向函数,而 apply / call 则直接会运行它,如果需要绑定 this 指向,又不想立即执行的话,可以使用 bind 方法,等需要使用时再调用绑定后的函数

bind 第一个参数为 this 指向,第二个至以后的参数为给绑定的函数预先传入的参数,预置参数的函数通常也被称为偏函数

function func(a,b,c,d) {
    console.log(this)
    console.log(a,b,c,d)
}

let boundFunc = func.bind({a:1},1,2)

boundFunc(3,4) // {a:1} 1,2,3,4

通过 bind 预先传入了参数 1,2,当调用绑定后的函数时,它会预先传入 1,2 作为第一第二个参数,此时再给函数传入参数,会作为第三第四的参数,最终打印 1,2,3,4

基本包装类型

为了便于操作基本类型的值,ECMAScript 提供了 3 个特殊的引用类型:

  • Boolean
  • Number
  • String

它们有别于 boolean,number,string ,可以发现它们首字母是大写,意味着它们是引用类型,也就是对象

JS 高级程序设计中说到:

每当读取一个基本类型值的时候,后台就会创建一个对应的基本包装类型对象,从而让我们能够调用一些方法来操作这些数据

var s1 = "some text"
var s2 = s1.substring(2)

我们要知道,基本类型是没有任何方法的,也就是说 "some text" 这个字符串是没有 substring 这个方法的,那为什么第二行代码不会报错呢?

原因在于,当第二行代码访问保存着字符串的变量 s1 时,会处于一种读取模式,尝试从内存中读取这个字符串的值,而在读取模式中访问字符串时,会有以下操作

  1. 创建 String 类型的一个实例
  2. 在实例上调用 substring 方法
  3. 还原成基本最初的基本类型

可以这样理解

var s1 = "some text"
// 读取模式
s1 = new String("some text")
var s2 = s1.substring(2)
s1 = "some text" // 还原成基本类型

String 包装类型作为引用类型,它是含有 substring 方法的,所以需要将基本类型转换为包装类型才能执行方法,并将返回的结果赋值给变量 s2,最后再将 s1 还原成一开始的基本类型

另外自动创建 (并非主动调用 new 创建的对象) 的包装类型只会存在于代码执行瞬间,然后立即销毁

直接调用 String 函数生成的是基本类型,而使用 new 关键字将 String 作为构造函数使用,则生成的是包装类型

let str = String("abc") // "abc" string 基本类型
let wrappedStr = new String("abc") // String {"abc"} String 包装类型

let boolean = Boolean(true) // true boolean 基本类型
let wrappedBoolean = new Boolean(true) // Boolean {true} Boolean 包装类型

let number = Number(123) // 123 number 基本类型
let wrappedNumber = new Number(123) // Number {123} Number 包装类型

一般情况下不推荐主动生成包装类型,因为容易和基本类型搞混

Number 包装类型

toFixed 是 Number 包装类型下的一个方法,用于按照指定小数位返回数值的字符串表示,当数值小于传入当参数,会四舍五入

let num = 10
console.log(num.toFixed(2)) // "10.00"

let num2 = 1.68
console.log(num.toFixed(1)) // "1.7"

但是使用 toFixed 时需要考虑到 Javascript 的小数的精度问题

let num = 1.335
console.log(num.toFixed(2)) // "1.33"
console.log(num.toFixed(50)) // "1.3349999999999999644..."

事实上数字 1.335 在语言底层并不是真的以 1.335 来存储的,通过 toFixed 方法返回小数点后 50 位可以发现,1.335 真正的值为 1.3349999... 之后就是无限的循环,由于 JS 最多能表示的精度的长度是 16,所有的小数都只会精确到小数点后 16 位同时自动凑整,所以就进位之后就得到了 1.335

由于代表的真实数字是 1.3349999...,所以 toFixed 四舍五入后的结果也就是 1.33 了,因为下一位是 4 被舍去了

String 包装类型

slice

slice 方法同时可以使用于数组类型和 String 包装类型,具体的特点可以看上面的 Array 章节

indexOf

indexOf 方法同时可以使用于数组类型和 String 包装类型,它会从第一个位置开始遍历,寻找参数在字符串(数组)中的位置并返回下标,同时它还接受第二个参数用于在指定的位置之后开始寻找

let str = "hello world"
// 从下标 6 的位置开始寻找字符串 o
// 但返回值仍相对于整个字符串的位置
console.log(str.indexOf('o',6)) // 7

尽管 ES6 有 includes 和 find 之类更强大的方法去寻找元素,但是如果需要从某个指定位置开始寻找元素,使用 indexOf 会更加的方便,以下例子会返回字符串中单词 e 的所有下标

let str = `
    React makes it painless to create interactive UIs.
     Design simple views for each state in your application, and React will efficiently update and render just the right components when your data changes
`
let arr = []
let pos = str.indexOf('e')
while (pos > -1) {
    arr.push(pos)
    pos = str.indexOf('e',pos + 1)
}
console.log(arr) // [6,14,25,34,37,42,49,62,73,77,85,94,122,132,138,149,156,159,169,183,190,208]

trim

trim 方法可以去除字符串首尾的空格,对于一些表单的输入是不允许有空格的,可以使用 trim 来去除,同时在 ES10 中,还有 trimStart 和 trimEnd 这两种方法,分别去除字符串前面和后面的空格

replace

replace 方法可以用来根据参数替换字符串,第一个参数可以是字符串也可以是正则,第二个参数可以是字符串也可以是函数

str.replace(regexp|substr, newSubStr|function)

当参数都是字符串时,只是简单的在 str 中找到第一个参数第一次出现的位置,并替换成第二个参数

当第一个参数是正则时,会替换和正则匹配的字符串,同时如果正则中含有 g 标志,会进行全局搜索,当第一次匹配到对应字符串后,不再停止匹配,而是继续往后搜索是否仍有可替换的字符串

同时第二个参数还可以传入函数,匹配到的字符串会替换为函数的返回值,函数的引入使得 replace 方法更加的灵活

let str = "hello world"
console.log(str.replace('l','x')) // "hexlo world" 只将第一个 l 替换为 x
console.log(str.replace(/l/,'x')) // "hexlo world" 同上
console.log(str.replace(/l/g,'x')) // "hexxo worxd" 通过给正则添加全局搜索的标志符,可以实现全局替换
console.log(str.replace(/l/, (match,index,str) => {
    console.log(match) // 'l' 匹配的子串
    console.log(index) // 2 匹配到的子字符串在原字符串中的偏移量
    console.log(str) // 'hello world' 被匹配的原字符串
    return 'q' // 返回字符串 q 代表将第一个匹配到的字符串 l 替换为 q
})) // "heqlo world"

另外如果第一个正则含有捕获组,那么第二个参数还可以拿到前面正则中的捕获组,更多详情可以查看 MDN

split

关于 split 方法用来分割字符串,除了我们常用的传入一个字符串外,其实 split 还支持传入一个正则作为参数,如果是一个包含捕获组的正则表达式,会将捕获组也放入最终返回的数组中

let str = "hello world"
console.log(str.split(/(l)/)) // [ 'he', 'l', '', 'l', 'o wor', 'l', 'd' ]

可以看到这里不仅通过分隔符 'l' 将字符串分割,还将分隔符也保存在了数组中

单体内置对象

事实上,并没有所谓的全局变量或全局函数,所有在全局作用域中定义的属性和函数,都是 Global 对象的属性,包括原生的引用类型都是 Global 对象的属性, Global 对象一般不允许被直接访问

之所以是 Global 对象而不是 window 对象,是因为 window 对象只在浏览器中存在,在 node 环境下,全局对象为 global,而在 webworker 中全局对象为 self,它们虽然都不是 Global 对象,但是都作为承担它的对象而存在

// 在 node 环境运行以下代码
console.log(global) // node 中的全局对象 global
console.log(window) // 报错 window 对象不存在

// 在 webworker 中不允许操作 DOM,也没有 window 对象

本系列第一篇中我提到过,在浏览器端,JavaScript 有三部分组成

  • ECMAScript
  • DOM
  • BOM

也就是说有一部分的方法并不属于 ECMAScript 的范畴,比如 BOM 提供的 alert,console 方法,或者 DOM 提供的 createElement,appendChild,换句话说,不只是 JavaScript,别的语言也能操作 DOM ,操作控制台等,浏览器只提供了访问它们的接口

JavaScript Weekly 这周正好给我推荐了一篇不错的文章,介绍 ECMAScript 最新的提案 globalThis,它存在与所有 JavaScript 运行的平台,并指向全局的 Global 对象

Explaining the globalThis ES Proposal

未完待续

参考资料

《JavaScript 高级程序设计第三版》

《你不知道的JavaScript》

MDN

JavaScript 浮点数陷阱及解法