面試說:聊聊JavaScript中的數據類型

前言

請講下 JavaScript 中的數據類型?

前端面試中,估計大家都被這麼問過。

答:Javascript 中的數據類型包括原始類型和引用類型。其中原始類型包括 null、undefined、boolean、string、symbol、bigInt、number。引用類型指的是 Object。

沒錯,我也是這麼回答的,只是這通常是第一個問題,由這個問題可以引出很多很多的問題,比如

  • Null 和 Undefined 有什麼區別?前端的判空有哪些需要注意的?
  • typeof null 為什麼是 object?
  • 為什麼 ES6 要提出 Symbol?
  • BigInt 解決了什麼問題?
  • 為什麼 0.1 + 0.2 !== 0.3? 你如何解決這個問題?
  • 如何判斷一個值是數組?
  • ...

弱類型語言

因為 JavaScript 是弱類型語言或者說是動態語言。這意味著你不需要提前聲明變數的類型,在程式運行的過程中,類型會被自動確定,也就是說你可以使用同一個變數保存不同類型的值

var foo = 42;  // foo is a Number now
foo = "bar";  // foo is a String now
foo = true;   // foo is a Boolean now

這一特性給我們帶來便利的同時,也給我們帶來了很多的類型錯誤。試想一下,假如 JS 說是強類型語言,那麼各個類型之間沒法轉換,也就有了一層隔閡或者說一層保護,會不會更加好維護呢?——這或許就是 TypeScript 誕生的原因。

對 JavaScript 的數據類型掌握,是一個前端最基本的知識點

null 還是 undefinded

定義

undefined 表示未定義的變數。null 值表示一個空對象指針。

追本溯源: 一開始的時候,JavaScript 設計者 Brendan Eich 其實只是定義了 null,null 像在 Java 里一樣,被當成一個對象。但是因為 JavaScript 中有兩種數據類型:原始數據類型和引用數據類型。Brendan Eich 覺得表示"無"的值最好不是對象。

所以 Javascript 的設計是 null是一個表示"無"的對象,轉為數值時為0;undefined是一個表示"無"的原始值,轉為數值時為NaN。

Number(null)
// 0

5 + null
// 5

Number(undefined)
// NaN

5 + undefined
// NaN

Null 和 Undefined 的區別和應用

null表示"沒有對象",即該處不應該有值。,典型的用法如下

  1. 作為函數的參數,表示該函數的參數不是對象。
  2. 作為對象原型鏈的終點。
Object.getPrototypeOf(Object.prototype)
// null

undefined表示"缺少值",就是此處應該有一個值,但是還沒有定義。典型用法是:

  1. 變數被聲明了,但沒有賦值時,就等於 undefined。
  2. 調用函數時,應該提供的參數沒有提供,該參數等於undefined。
  3. 對象沒有賦值的屬性,該屬性的值為 undefined。
  4. 函數沒有返回值時,默認返回 undefined。
var i;
i // undefined

function f(x){console.log(x)}
f() // undefined

var  o = new Object();
o.p // undefined

var x = f();
x // undefined

判空應該注意什麼?

javaScript 五種空值和假值,分別為 undefined,null,false,"",0,NAN

這有時候很容易導致一些問題,比如

let a = 0;
console.log(a || '/'); // 本意是只要 a 為 null 或者 Undefined 的時候,輸出 '/',但實際上只要是我們以上的五種之一就輸出 '/'

當然我們可以寫成

let a = 0;
if (a === null || a === undefined) {
  console.log('/');
} else {
  console.log(a);
}

始終不是很優雅,所以 ES規範 提出了空值合併操作符(??)

空值合併操作符(??)是一個邏輯操作符,當左側的操作數為 null 或者 undefined 時,返回其右側操作數,否則返回左側操作數。

上面的例子可以寫成:

let a = 0;
console.log(a??'/'); // 0

typeof null——JS 犯的錯

typeof null // "object"

JavaScript 中的值是由一個表示類型的標籤和實際數據值表示的。第一版的 JavaScript 是用 32 位比特來存儲值的,且是通過值的低 1 位或 3 位來識別類型的,對象的類型標籤是 000。如下

  • 1:整型(int)
  • 000:引用類型(object)
  • 010:雙精度浮點型(double)
  • 100:字元串(string)
  • 110:布爾型(boolean)

但有兩個特殊值:

  • undefined,用整數−2^30(負2的30次方,不在整型的範圍內)
  • null,機器碼空指針(C/C++ 宏定義),低三位也是000

由於 null 代表的是空指針(低三位也是 000 ),因此,null 的類型標籤是 000,typeof null 也因此返回 "object"。

這個算是 JavaScript 設計的一個錯誤,但是也沒法修改,畢竟修改的話,會影響目前現有的程式碼

Number——0.1+0.2 !== 0.3

現象

在 JavaScript 會存在類似如下的現象

0.1 + 0.2 
0.30000000000000004

原因

我們在對浮點數進行運算的過程中,需要將十進位轉換成二進位。十進位小數轉為二進位的規則如下:

對小數點以後的數乘以2,取結果的整數部分(不是1就是0),然後再用小數部分再乘以2,再取結果的整數部分……以此類推,直到小數部分為0或者位數已經夠了就OK了。然後把取的整數部分按先後次序排列

根據上面的規則,最後 0.1 的表示如下:

0.000110011001100110011(0011無限循環)……

所以說,精度丟失並不是語言的問題,而是浮點數存儲本身固有的缺陷。

JavaScript 是以 64 位雙精度浮點數存儲所有 Number 類型值,按照 IEEE754 規範,0.1 的二進位數只保留 52 位有效數字,即

1.100110011001100110011001100110011001100110011001101 * 2^(-4)

同理,0.2的二進位數為

1.100110011001100110011001100110011001100110011001101 * 2^(-3)

這樣在進位之間的轉換中精度已經損失。運算的時候如下

0.00011001100110011001100110011001100110011001100110011010
+0.00110011001100110011001100110011001100110011001100110100
------------------------------------------------------------
=0.01001100110011001100110011001100110011001100110011001110

所以導致了最後的計算結果中 0.1 + 0.2 !== 0.3

如何解決

  • 將數字轉成整數
function add(num1, num2) {
 const num1Digits = (num1.toString().split('.')[1] || '').length;
 const num2Digits = (num2.toString().split('.')[1] || '').length;
 const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
 return (num1 * baseNum + num2 * baseNum) / baseNum;
}
  • 類庫
    NPM 上有許多支援 JavaScript 和 Node.js 的數學庫,比如 math.js,decimal.js,D.js 等等

  • ES6
    ES6 在 Number 對象上新增了一個極小的常量——Number.EPSILON

Number.EPSILON
// 2.220446049250313e-16
Number.EPSILON.toFixed(20)
// "0.00000000000000022204"

引入一個這麼小的量,目的在於為浮點數計算設置一個誤差範圍,如果誤差能夠小於 Number.EPSILON,我們就可以認為結果是可靠的。

function withinErrorMargin (left, right) {
    return Math.abs(left - right) < Number.EPSILON
}
withinErrorMargin(0.1+0.2, 0.3)

未來的解決方案——TC39 Decimal proposal

目前處於 Stage 1 的提案。後文提到的 BigInt 擴展的是 JS 的正數邊界,超過 2^53 安全整數問題。Decimal 則是解決JS的小數問題-2^53。這個議案在JS中引入新的原生類型:decimal(後綴m),聲明這個數字是十進位運算。

let zero_point_three = 0.1m + 0.2m;
assert(zero_point_three === 0.3m);
// 提案中的例子
function calculateBill(items, tax) {
  let total = 0m;
  for (let {price, count} of items) {
    total += price * BigDecimal(count);
  }
  return BigDecimal.round(total * (1m + tax), {maximumFractionDigits: 2, round: "up"});
}

let items = [{price: 1.25m, count: 5}, {price: 5m, count: 1}];
let tax = .0735m;
console.log(calculateBill(items, tax));

拓展——浮點數在記憶體中的存儲

所以最終浮點數在記憶體中的存儲是什麼樣的呢?EEE754 對於浮點數表示方式給出了一種定義

(-1)^S * M * 2^E

各符號的意思如下:S,是符號位,決定正負,0時為正數,1時為負數。M,是指有效位數,大於1小於2。E,是指數位。

Javascript 是 64 位的雙精度浮點數,最高的 1 位是符號位S,接著的 11 位是指數E,剩下的 52 位為有效數字M。

可藉助 這個可視化工具 查看浮點數在記憶體中的二進位表示)

BigInt——突破最大的限制

JavaScript 的 Number 類型為 雙精度IEEE 754 64位浮點類型。
在 JavaScript 中最大的值為 2^53。

BigInt 任意精度數字類型,已經進入stage3規範。BigInt 可以表示任意大的整數。要創建一個 BigInt ,我們只需要在任意整型的字面量上加上一個 n 後綴即可。例如,把123 寫成 123n。這個全局的 BigInt(number) 可以用來將一個 Number 轉換為一個 BigInt,言外之意就是說,BigInt(123) === 123n。現在讓我來利用這兩點來解決前面我們提到問題:

Symbol——我是獨一無二最靚的仔

定義

ES6 引入了一種新的原始數據類型 Symbol,表示獨一無二的值

let s = Symbol();

typeof s
// "symbol"

應用場景

  • 定義一組常量,保證這組常量都是不相等的。消除魔法字元串

  • 對象中保證不同的屬性名

let mySymbol = Symbol();

// 第一種寫法
let a = {};
a[mySymbol] = 'Hello!';

// 第二種寫法
let a = {
  [mySymbol]: 'Hello!'
};

// 第三種寫法
let a = {};
Object.defineProperty(a, mySymbol, { value: 'Hello!' });

// 以上寫法都得到同樣結果
a[mySymbol] // "Hello!"
  • Vue 中的 provide 和 inject。provide 和 inject 可以允許一個祖先組件向其所有子孫後代注入一個依賴,不論組件層次有多深,並在起上下游關係成立的時間裡始終生效。但這個侵入性也是非常強的,使用 Symbols 作為 key 可以避免對減少對組件程式碼干擾,不會有相同命名等問題

數組——對象中一個特殊的存在

請說下判斷 Array 的方法?

為什麼會問這個問題?

因為數組是一個特殊的存在,是我們平時接觸得最多的數據結構之一,它是一個特殊的對象,它的索引就是「普通對象」的 key 值。但它又擁有一些「普通對象」沒有的方法,比如 map 等

typeof 是 javascript 原生提供的判斷數據類型的運算符,它會返回一個表示參數的數據類型的字元串。但我們不能通過 typeof 判斷是否為數組。因為 typeof 數組和普通對象以及 null,都是返回 "object"

const a = null;
const b = {};
const c= [];
console.log(typeof(a)); //Object
console.log(typeof(b)); //Object
console.log(typeof(c)); //Object

判斷數組的方法

  • Object.prototype.toString.call()。
    每一個繼承 Object 的對象都有 toString 方法,如果 toString 方法沒有重寫的話,會返回 [Object type],其中 type 為對象的類型
const a = ['Hello','Howard'];
const b = {0:'Hello',1:'Howard'};
const c = 'Hello Howard';
Object.prototype.toString.call(a);//"[object Array]"
Object.prototype.toString.call(b);//"[object Object]"
Object.prototype.toString.call(c);//"[object String]"
  • Array.isArray()
const a = [];
const b = {};
Array.isArray(a);//true
Array.isArray(b);//false

Array.isArray() 是 ES5 新增的方法,當不存在 Array.isArray() ,可以用 Object.prototype.toString.call() 實現

if (!Array.isArray) {
  Array.isArray = function(arg) {
    return Object.prototype.toString.call(arg) === '[object Array]';
  };
}
  • instanceof。instanceof 運算符可以用來判斷某個構造函數的 prototype 屬性所指向的對象是否存在於另外一個要檢測對象的原型鏈上。因為數組的構造函數是 Array,所以可以通過以下判斷。注意:因為數組也是對象,所以 a instanceof Object 也為 true
const a = [];
const b = {};
console.log(a instanceof Array);//true
console.log(a instanceof Object);//true,在數組的原型鏈上也能找到Object構造函數
console.log(b instanceof Array);//false
  • constructor。通過構造函數實例化的實例,擁有一個 constructor 屬性。
function B() {};
let b = new B();
console.log(b.constructor === B) // true

而數組是由一個叫 Array 的函數實例化的。所以可以

let c = [];
console.log(c.constructor === Array) // true

注意:constructor 是會被改變的。所以不推薦這樣判斷

let c = [];
c.constructor = Object;
console.log(c.constructor === Array); // false

結論

根據上面的描述,個人推薦的判斷方法有如下的優先順序

isArray > Object.prototype.toString.call() > instanceof > constructor

總結

本文針對於 JavaScript 中部分常見的數據類型問題進行了討論和分析。希望對大家面試或者平時的工作都能有所幫助。另外可能沒有提及的比如類型轉換等有機會再討論一下

最後,歡迎大家關注我的公眾號——前端雜貨鋪,技術問題多討論~

參考