深度:JS的7種資料型別以及它們的底層資料結構

語言: CN / TW / HK

基本型別:String、Boolean、Number、Undefined、Null、Symbol

引用型別:Object

下面我會詳細說一下這七種資料型別中可能你不太知道的一些細節。

1. String

儲存結構

計算機是以二進位制儲存以及傳送接收資料的。二進位制的 1位,也叫做 1bit 。它是計算機內部儲存的最基本的單位。

計算機只能處理數字,如果想要處理文字,必須將文字轉換為數字才能處理,在計算機中 1bit 能表示2個狀態(0或1),1Byte 等於 8bit,所以 1Byte 能表示 2^8 - 1 個整數,也就是255個。如果想表示更大的數字,就需要更多的位元組數,比如 2Byte 能表示 2^16 - 1 ,也就是 65535個整數。最早只有127個字元被編碼到計算機裡,也就是大小寫英文字母、數字和一些其他字元,這個編碼表就是 ASCII 表。

但如果要表示中文,那麼 1Byte 顯然是不夠的,至少需要 2Byte ,所以中國製定了 GB2312 編碼,但每個國家如果都制定一個自己的編碼表,那麼就沒辦法正確顯示多語言混合的文字。為了解決這個問題,Unicode 編碼應運而生。它把所有語言統一到一個編碼裡,採用 2Byte 表示一個字元,即最多可以表示 2^16 - 1 ,也就是 65535 個字元。這樣基本上可以覆蓋世界上常用的文字,如果要表示更多的文字,也可以採用 4Byte 進行編碼,這是一種通用的編碼規範 。

JS 中的字元也採用Unicode編碼,也就是js中的中英文字元都佔用 2Byte(16bit)大小。

在 JS 中的二進位制資料儲存中,二進位制前三位為 100 代表字串

基本包裝型別

在 js 中,只有引用型別才有屬性或者方法,基本型別理論上沒有屬性或方法,而字串又屬於基本型別,但為什麼字串能呼叫一些屬性和方法呢?

原因是 js 為了方便對字串進行操作,ECMA 提供了一個基本包裝型別 String物件。它是一種特殊的引用型別, 當 js 引擎需要讀取或操作一個字串時,他會在內部建立一個 String型別的包裝物件例項,然後呼叫這個例項的屬性或方法並返回結果後,再立即清除這個例項。

這也是為什麼 字串明明是一個基本型別 卻能呼叫屬性和方法的原因。

幾個Unicode問題總結

基本概念

Unicode 是目前最常見的字元編碼,它用一個碼位對映一個字元。在 js 中,Unicode 碼位範圍為 '\u{0000}' ~ '\u{10ffff}' ,可以表示超過110萬個字元。格式為 '\u{十六進位制數字}'

console.log('\u{0041}') // 'A'
console.log('\u{0061}') // 'a'
console.log('I \u{2661} Hagan') // 'I ♡ Hagan'
console.log('\u{20bb7}') // '  '

Unicode 最前面的 65536 個字元位稱為 基本多文種平面,它的碼位範圍為 '\u{0000}' ~ '\u{ffff}' ,最常見的字元都放在這個平面上。

剩下的字元都放在 輔助平面 上,碼位範圍為 '\u{010000}' ~ '\u{10ffff}'

判斷是否為輔助平面的方法為十六進位制數字的位數是否超過4位。

字串長度問題

  1. 解決代理對長度問題

在內部,JavaScript 將輔助平面內的字元表示為代理對,並將單獨的代理對分開為單獨的 “字元”,所以代理對的length屬性可能與我們的預期不一致,比如:

const str = '\u{20BB7}'
console.log(str) // '  '
console.log(str.length) // 2

而我們想獲取的長度應該為 1,這裡可以使用以下方法正確獲取長度

const str = '\u{20BB7}'
console.log(Array.from(str).length) // 1
  1. 解決組合標記長度問題

\u{0307} 表示 q̣̇ 上面的點, \u{0323} 表示 q̣̇ 下面的點,這三個字元共同組成了一個 q̣̇ ,如下程式碼

const str = 'q\u{0307}\u{0323}'
console.log(str) // `q̣̇`
console.log(str.length) // 3

我們期待拿到的長度應該為1,可實際拿到的length為3,這裡可以使用以下方法獲取長度

const str = 'q\u{0307}\u{0323}'

const regex = /(\P{Mark})(\p{Mark}+)/gu
const trim = str.replace(regex, ($0, $1, $2) => $1)
console.log(Array.from(str).length) // 1
  1. 將以上程式碼封裝起來,可封裝成以下方法

注意:此方法無法處理表情字元序列組合 ':man:‍:woman:‍:girl:‍:boy:'

const getStringLength = function (string) {
  const regex = /(\P{Mark})(\p{Mark}+)/gu
  const str = string.replace(regex, ($0, $1, $2) => $1)
  return Array.from(str).length
}

export default getStringLength

字串反轉

注意:此方法無法正確處理組合標記

const getReverseString = function (string) {
  return Array.from(string).reverse().join('')
}

export default getReverseString

一位名叫 Missy Elliot 的聰明的電腦科學家提出了一個防彈演算法來解決組合標記問題

根據碼位獲取字串

注意:此方法無法正確處理組合標記

String.fromCodePoint(0x20bb7) // '  '

根據字串獲取碼位

注意:此方法無法正確處理組合標記

'  '.codePointAt().toString(16) // 20bb7

遍歷字串

注意:此方法無法正確處理組合標記

for (const item of '  ') {
  console.log(item) // '  '
}

Number

儲存結構

js採用 IEEE754 標準中的 雙精度浮點數來表示一個數字,標準規定雙精度浮點數採用 64 位儲存,即 8 個位元組表示一個浮點數。儲存結構如下圖:

在雙精度浮點數中,第一位的 1bit符號位 決定了這個數的正負,指數部分的 11bit 決定數值大小,小數部分的 52bit 決定數值精度。

在 JS 中的二進位制資料儲存中,二進位制前三位為 010 代表雙精度數字

數值範圍

指數部分為 11bit 也就是 2^11 - 1 = 2047,取中間值進行偏移得到 [-1023, 1024],因此這種儲存結構能夠表示的數值範圍為 2^-1023 至 2^1024,超出這個範圍無法表示。轉換為科學計數法為:

2^-1023 = 5 × 10^-324
2^1024 = 1.7976931348623157 × 10^308
Number.MAX_VALUE // 1.7976931348623157e+308
Number.MIN_VALUE // 5e-324

安全整數

IEEE754 規定,有效數字第一位預設總是1,但它不儲存在 64 位浮點數之中。所以有效數字為 52bit + 1 = 53bit。

這意味著js能表示並進行精確算術運算的安全整數範圍為 [-2^53 -1, 2^53 - 1] 即 -9007199254740991 到最大值 9007199254740991 之間的範圍。

Math.pow(2, 53) - 1 // 9007199254740991
-Math.pow(2, 53) - 1 // -9007199254740991

可以通過 Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER 來分別獲取安全整數最大值和最小值。

console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991
console.log(Number.MIN_SAFE_INTEGER) // -9007199254740991

對於超過這個安全整數的運算,需要使用 BigInt 來計算。

console.log(9007199254740991 + 2) // 9007199254740992
console.log(BigInt(9007199254740991) + BigInt(2)) // 9007199254740993n

精度丟失

計算機中的數字都是用二進位制儲存的,如果要計算 0.1 + 0.2 那麼計算機會分別把 0.1 和 0.2 轉成二進位制,然後相加,最後把相加的結果轉為10進位制。

但有一些浮點數轉化為二進位制時會出現無限迴圈,比如 0.1

0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 10001 無限迴圈

而上面我們說過計算機能儲存的小數位最多隻有 53 位,為了儘可能的接近目標值,所以採用類似十進位制四捨五入的方法,在二進位制中 0舍1入,最終 0.1 儲存到計算機中成為以下數值。

0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 101

0.1轉換成科學技術法

(−1)^0 × 2^(-4) × (1.1001100110011001100110011001100110011001100110011010)2

0.2科學技術法表示為

(−1)^0 × 2^(-3) × (1.1001100110011001100110011001100110011001100110011010)2

在浮點數做加法時要先進行對位操作,將較小的指數轉化為較大的指數,並將小數部分右移

(−1)^0 × 2^(-3) × (0.111001100110011001100110011001100110011001100110011010)2
(−1)^0 × 2^(-3) × (1.1001100110011001100110011001100110011001100110011010)2

最終 0.1 + 0.2 在計算機中的計算過程如下

計算後 0.1 + 0.2 的結果為

(−1)^0 × 2^(−2) × (1.0011001100110011001100110011001100110011001100110100)2

然後通過 js 將二進位制轉為10進位制

(-1)**0 * 2**-2 * (0b10011001100110011001100110011001100110011001100110100 * 2**-52) === 0.30000000000000004 // true
console.log(0.1 + 0.2) ; // 0.30000000000000004

這就是經典的 0.30000000000000004 問題,0.1 和 0.2 在轉換為二進位制時由於做了 0舍1入 ,發生了一次精度丟失,而對於計算後的二進位制又 做了一次 0舍1入 發生一次精度丟失,因此得到的結果是不準確的。

解決辦法:

精度丟失解決辦法就是先將小數轉換成整數,然後用整數進行計算得到結果後再轉換為小數,用 js 封裝方法如下

math.js

// 判斷number是否為一個整數
const isInteger = function (number) {
  return Math.floor(number) === number
}

// 四捨五入
const toFixed = function (number, decimalLength = 0) {
  var times = Math.pow(10, decimalLength)
  var fixed = number * times + 0.5
  return parseInt(fixed) / times
}

// 將一個浮點數轉成整數,返回整數和倍數
const toInteger = function (floatNumber) {
  const numberInfo = { times: 1, number: 0 }
  const isNegative = floatNumber < 0
  if (isInteger(floatNumber)) {
    numberInfo.number = floatNumber
    return numberInfo
  }
  const stringFloatNumber = String(floatNumber)
  const dotPosition = stringFloatNumber.indexOf('.')
  const length = stringFloatNumber.substr(dotPosition + 1).length
  numberInfo.times = Math.pow(10, length)
  numberInfo.number = toFixed(Math.abs(floatNumber) * numberInfo.times)
  if (isNegative) numberInfo.number = -numberInfo.number
  return numberInfo
}

// 加
export const add = function (number1, number2, decimalLength = 0) {
  const { number: num1, times: times1 } = toInteger(number1)
  const { number: num2, times: times2 } = toInteger(number2)
  const maxTimes = Math.max(times1, times2)
  let result
  if (times1 === times2) result = (num1 + num2) / maxTimes
  if (times1 > times2) result = (num1 + num2 * (times1 / times2)) / maxTimes
  if (times1 < times2) result = (num1 * (times2 / times1) + num2) / maxTimes
  return toFixed(result, decimalLength)
}
import { add } from './math.js'
console.log(add(0.1, 0.2, 1)) // 0.3

特殊數值變數

JavaScript 提供了幾個特殊數值,用於判斷數字的邊界和其他特性

Number.MAX_VALUE // JavaScript 中的最大值
Number.MIN_VALUE // JavaScript 中的最小值
Number.MAX_SAFE_INTEGER // 最大安全整數,為 2^53 - 1
Number.MIN_SAFE_INTEGER // 最小安全整數,為 -(2^53 - 1)
Number.POSITIVE_INFINITY // 對應 Infinity,代表正無窮
Number.NEGATIVE_INFINITY // 對應 -Infinity,代表負無窮
Number.EPSILON // 是一個極小的值,用於檢測計算結果是否在誤差範圍內
Number.NaN // 表示非數字,NaN與任何值都不相等,包括NaN本身
Infinity // 表示無窮大,分 正無窮 Infinity 和 負無窮 -Infinity

四捨五入

有時我們需要對一些數字進行四捨五入,而這些數字可能包含小數

Math.round(number) 方法無法對小數進行計算

Number.toFixed() 方法實際上採用四捨六入五成雙的規則實現,存在一些缺陷,具體可看一下這篇文章

以上兩個方法有時候無法滿足我們的需求,所以封裝以下方法

math.js

// 四捨五入
export const toFixed = function (number, decimalLength = 0) {
  var times = Math.pow(10, decimalLength)
  var fixed = number * times + 0.5
  return parseInt(fixed) / times
}
import { toFixed } from './math.js'

toFixed(0.2286298683746, 3) // 0.229

Boolean

儲存結構

在 JS 中的二進位制資料儲存中,二進位制前三位為 110 代表布林值

基本概念

Boolean 只有兩個型別,true、false。在js中所有型別的值都能轉換成 Boolean 值。如下程式碼

Boolean('') // false // 除了空字串意外,其他字串都為true
Boolean(0 || NaN) // false // 除了0與NaN,其他數字都為true
Boolean(undefined) // false
Boolean(Symbol()) // true
Boolean(null) // false
Boolean({} && []) // true // 所有引用型別都為true

隱式型別轉換

當使用以下操作符獲取 Boolean 結果時,在過程中 js 內部會先進行隱式型別轉換,再使用轉換後的結果進行對比,最終確定為 true、 false。

> >= < <= == != if else while

4 > 3 // 4 > 3 // true
'4' > 3 // Number('4') > 3 // 4 > 3 // true
'a' > 'b' // 'a'.codePointAt() > 'b'.codePointAt() // 97 > 98 // false
true > 2 // Number(true) > 2 // 1 > 2 // false
undefined > 0 // Number(undefined) > 0 // NaN > 0 // false
null > 0 // Number(null) > 0 // NaN > 0 // false
new Date() > 100 // new Date().valueOf() > 100 // 1587608237665 > 100 // true
if (1) { } // Boolean(1) // true
if (!(1 > '10')) { } // !(1 > Number('10')) // !(1 > 10) //!false // true

顯式型別轉換

與隱式型別轉換相對應,當使用 ! 操作符時,會將變數強制轉換為 Boolean 值,並進行取反操作。

!1 // false
!!1 // true
!undefined // true
!!undefined // false

全等操作符

=== !==

全等操作符在進行對比出Boolean結果時不會進行隱式型別轉換,為了避免因隱式型別轉換帶來的預期之外的情況,推薦在實際專案中使用全等操作符來進行對比。

'1' == 1 // Number('1') == 1 // 1 == 1 // true
'1' === 1 // false
true != 1 // Number(true) != 1 // 1 != 1 // false
true !== 1 // true

邏輯操作符

以下兩個操作符為邏輯操作符

&& ||

1 && 2 // 2 // 取最後一個為 true 的值
1 || 2 // 1 // 取第一個為 true 的值

Symbol

儲存結構

這是 ES6 新增的一種資料型別,它的字面意思為,符號,標記。代表獨一無二的值。

基本概念

Symbol 型別可以作為物件的 key 值。Symbol 最常用的方式就是作為唯一 id。

class Person {
  constructor (name, age) {
    this.name = name
    this.age = age
    this.id = Symbol('身份證號')
  }
}

const hagan1 = new Person('hagan', 25)
hagan1.id // Symbol(身份證號)

const hagan2 = new Person('hagan', 24)
hagan2.id // Symbol(身份證號)

hagan1.id === hagan2.id // false // 身份證號永遠唯一

Symbol 也可以當成私有變數來使用,但它並不是真的為私有。

Girl.js

const _age = Symbol('女生的年齡')

export default class {
  constructor (name, age) {
    this.name = name
    this[_age] = age // 女生實際年齡
  }
  getAge () {
    return 18 // 女生告訴你的年齡
  }
}

rita.js

import 'Girl' from './Girl.js'

const rita = new Girl('rita', 28)

// 女生實際年齡只有她自己知道,而你毫無辦法,因為女生永遠 18 歲
rita.age // undefined
rita[Symbol('女生的年齡')] // undefined
rita.getAge() // 18

// 除非你開掛
const [ _age ] = Object.getOwnPropertySymbols(rita)
rita[_age] // 36

Symbol 作為物件的屬性,不會被 for in for of 迴圈到,也不會被 Object.keys() Object.getOwnPropertyNames() JSON.stringify() 返回,但是它也不是任何辦法都無法訪問, Object.getOwnPropertySymbols(object) 方法能夠獲取到物件的所有 Symbol 型別的屬性名。

Symbol.for()

Symbol.for() 也可以生成 Symbol 值,他與直接呼叫 Symbol() 唯一的區別就是, Symbol.for() 生成的值不能作為唯一id

const hagan1 = Symbol.for('hagan')
const hagan2 = Symbol.for('hagan')

hagan1 === hagan2 // true

Symbol.keyFor()

返回通過 Symbol.for() 方法建立的 Symbol 型別的 key 值

const hagan = Symbol.for('hagan')

Symbol.keyFor(hagan) // 'hagan'

Undefined

儲存結構

基本型別之一的 Undefined 只擁有一個值 undefined ,代表未定義的值。

let name
console.log(name) // undefined

const hagan = { }
console.log(hagan.job) // undefined

const arr = [ ]
console.log(arr[0]) // undefined

(function (a, b) {
  console.log(a) // 1
  console.log(b) // undefined
})(1, 2)

凡是未被定義和賦值的變數、屬性或引數,都預設為 undefined

Null

儲存結構

在 JS 中的二進位制資料儲存中,Null 的全部數位都為 0

基本概念

基本型別之一的 Null 只擁有一個值 null ,代表空值。表示一個變數被人為重置為空物件,在記憶體中的表示就是棧中的變數即不是其他5中基本型別,也沒有引用型別中指向堆中的指標。當一個引用型別變數被賦值為 null 時,原來的引用型別物件在堆中處於遊離狀態,GC 會擇機回收該物件並釋放記憶體。因此想要回收哪個變數。就將它設為 null 就好了

為什麼 typeof null 會被判斷為 object

在 JS 中。資料在底層都是以二進位制儲存,引用型別的二進位制前三位為 0typeof 是根據這個特性來進行判斷型別的工作,可這裡有一個問題就是, null 型別所有位數都為 0 ,所以它的前三位也為 0 ,所以 null 會被判斷為 object

Object

基本概念

Object型別 也叫引用型別,是一組沒有特定順序的值的集合。

儲存結構

在JS中,基本型別的實際值儲存在 中,而引用型別的實際值儲存在 中。棧中儲存的只有指向到堆中的 指標 ,這也是Object型別也被稱為 引用型別 的原因。

在 JS 中的二進位制資料儲存中,二進位制前三位為 000 代表引用型別

JS中的堆記憶體與棧記憶體

請看下面的程式碼

const num = 1
console.log(num) // 1
num = 2 // 報錯

const hagan = { name: 'hagan' }
console.log(hagan) // { name: 'hagan' }
hagan.name = 'han'
console.log(hagan) // { name: 'han' }

const不是定義常量麼?為什麼還能改?這時候就要涉及到 JS 中的堆記憶體與棧記憶體了

在js引擎中對變數的儲存主要有兩種位置, 堆記憶體和棧記憶體

棧記憶體主要用於儲存各種 基本型別的 變數,包括Boolean、Number、String、Undefined、Null,以及物件變數的指標。

堆記憶體 主要負責像物件Object這種變數型別的儲存,如下圖

引用型別的資料的地址指標是儲存於棧中的,當我們想要訪問引用型別的值的時候,需要先從棧中獲得物件的地址指標,然後在通過地址指標找到堆中的所需要的資料。

因此當我們定一個 const 常量時,不可改變的只是 棧記憶體 中的資料,但 堆記憶體 中的資料還是可以通過變數的引用進行改變的。

總結: 棧記憶體 用來儲存基本型別,以及引用型別的指標, 堆記憶體 用來儲存引用型別資料。

物件拷貝

深拷貝

const hagan = { name: 'hagan', age: 25 }
const haganDeepClone = JSON.parse(JSON.stringify(hagan))
console.log(haganDeepClone === hagan) // false

淺拷貝

const hagan = { name: 'hagan', age: 25 }
const haganClone = Object.assign({}, hagan)
console.log(haganClone === hagan) // false

資料屬性與訪問器屬性

資料屬性

const hagan = { age: 22 }
Object.defineProperty(hagan, 'name', {
  configurable: false, // 能否通過delete刪除,呼叫defineProperty前預設為true,呼叫後預設為false
  enumerable: false, // for in 能否迴圈到,呼叫defineProperty前預設為true,呼叫後預設為false
  writable: false, // 是否可修改,呼叫defineProperty前預設為true,呼叫後預設為false
  value: 'hagan' // 屬性值,預設為undefined
})

delete hagan.name
console.log(hagan.name) // 'hagan'

for (let attr in hagan) {
    console.log(hagan[attr]) // 22
}

hagan.name = 'rita'
console.log(hagan.name) // 'hagan'

訪問器屬性

const hagan = { age: 25 }
Object.defineProperty(hagan, 'name', {
    get () {
        return this._name
    },
    set (value) {
        this._name = value
    }
})

hagan.name = 'hagan'
console.log(hagan) // { age: 25, _name: "hagan" }
console.log(hagan.name) // 'hagan'

一些Object方法整理

Object.create()

以第一個引數為原型,建立新物件。可用於原型繼承

function Animal (type) {
  this.type = type
}
Animal.prototype.getType = function () {
  return this.type
}

function People (name) {
  Animal.call(this, 'people') // 繼承屬性
  this.name = name
}
People.prototype = Object.create(Animal.prototype)
People.prototype.getName = function () {
  return this.name
}

const hagan = new People('hagan')
hagan.getName() // 'hagan'
hagan.getType() // 'people'

Object.defineProperty()

用於定義物件的資料屬性和訪問器屬性

const hagan = { age: 25 }
Object.defineProperty(hagan, 'name', {
    get () {
        return this._name
    },
    set (value) {
        this._name = value
    }
})

hagan.name = 'hagan'
console.log(hagan) // { age: 25, _name: "hagan" }
console.log(hagan.name) // 'hagan'

Object.defineProperties()

用於定義物件的資料屬性和訪問器屬性

const hagan = { age: 25 }
Object.defineProperties(hagan, {
  name: {
    value: 'hagan'
  }
})

hagan.name = 'hagan'
console.log(hagan) // { age: 25, _name: "hagan" }
console.log(hagan.name) // 'hagan'

Object.getOwnPropertyDescriptor()

獲取物件某屬性的資料屬性或訪問器屬性

const hagan = { age: 22 }
Object.defineProperty(hagan, 'name', {
    value: 'hagan'
})
Object.getOwnPropertyDescriptor(hagan, 'name') // {value: "hagan", writable: false, enumerable: false, configurable: false}

Object.getOwnPropertyNames()

獲取所有的屬性名並返回一個數組,不包含原型鏈

const hagan = { age: 22 }
Object.defineProperty(hagan, 'name', {
  configurable: false, // 能否通過delete刪除,呼叫defineProperty前預設為true,呼叫後預設為false
  enumerable: false, // for in 能否迴圈到,呼叫defineProperty前預設為true,呼叫後預設為false
  writable: false, // 是否可修改,呼叫defineProperty前預設為true,呼叫後預設為false
  value: 'hagan' // 屬性值,預設為undefined
})

Object.getOwnPropertyNames(hagan) // ["age", "name"]

Object.keys()

獲取所有的可列舉屬性名並返回一個數組,不包含原型鏈

const hagan = { age: 22 }
Object.defineProperty(hagan, 'name', {
  configurable: false, // 能否通過delete刪除,呼叫defineProperty前預設為true,呼叫後預設為false
  enumerable: false, // for in 能否迴圈到,呼叫defineProperty前預設為true,呼叫後預設為false
  writable: false, // 是否可修改,呼叫defineProperty前預設為true,呼叫後預設為false
  value: 'hagan' // 屬性值,預設為undefined
})

Object.keys(hagan) // ["age"]

Object.preventExtensions()

使物件不能新增屬性,但屬性的值可以刪除和修改

const hagan = { age: 22 }
Object.preventExtensions(hagan)

hagan.name = 'hagan'
console.log(hagan.name) // undefined

Object.seal()

使物件不能新增屬性,也不可以刪除,但可以修改

Object.freeze()

使物件不能新增屬性,也不可以刪除和修改

關注我的公眾號,一起學習吧~