面試說:聊聊JavaScript中的資料型別

2022-09-24 12:01:30

前言

請講下 JavaScript 中的資料型別?

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

答:Javascript 中的資料型別包括原始型別和參照型別。其中原始型別包括 nullundefinedbooleanstringsymbolbigIntnumber。參照型別指的是 Object

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

  • NullUndefined 有什麼區別?前端的判空有哪些需要注意的?
  • 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 其實只是定義了 nullnull 像在 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 的型別標籤是 000typeof 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 上有許多支援 JavaScriptNode.js 的數學庫,比如 math.jsdecimal.js,D.js 等等

  • ES6
    ES6Number 物件上新增了一個極小的常數——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——突破最大的限制

JavaScriptNumber 型別為 雙精度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 中的 provideinjectprovideinject 可以允許一個祖先元件向其所有子孫後代注入一個依賴,不論元件層次有多深,並在起上下游關係成立的時間裡始終生效。但這個侵入性也是非常強的,使用 Symbols 作為 key 可以避免對減少對元件程式碼干擾,不會有相同命名等問題

陣列——物件中一個特殊的存在

請說下判斷 Array 的方法?

為什麼會問這個問題?

因為陣列是一個特殊的存在,是我們平時接觸得最多的資料結構之一,它是一個特殊的物件,它的索引就是「普通物件」的 key 值。但它又擁有一些「普通物件」沒有的方法,比如 map

typeofjavascript 原生提供的判斷資料型別的運運算元,它會返回一個表示引數的資料型別的字串。但我們不能通過 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]';
  };
}
  • instanceofinstanceof 運運算元可以用來判斷某個建構函式的 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 中部分常見的資料型別問題進行了討論和分析。希望對大家面試或者平時的工作都能有所幫助。另外可能沒有提及的比如型別轉換等有機會再討論一下

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

參考