先從變數開始談起


Posted by saffran on 2021-02-25

變數的資料型態

在 JavaScript 裡面,共有七種資料型態

前六種稱為 primitive type
primitive type(原始型態)

  1. null
  2. undefined
  3. string
  4. number
  5. boolean
  6. symbol (ES6)

除了 primitive type 之外,其他的都是 object(物件)

  1. object (包括 array, function, date ...)

typeof 來得知變數的資料型態是什麼

但是,用 typeof 並不能保證最後出來的結果會是這七種型態(這是一個滿混淆人的地方),typeof 有一個表格會列出每一種變數出來的結果是什麼

例如:

  • function 的資料型態是 object,但是如果用 typeof 對 function,出來的結果會是 function
    console.log(typeof function () {});
    
  • null 的資料型態是 null,但是如果用 typeof 對 null,出來的結果會是 object
    這是 JavaScript 從以前到現在最廣為人知的一個 bug,但是沒人敢去修它,因為修了怕很多東西會壞掉
    console.log(typeof null);
    

typeof null 會是 object 的原因

官方文件 typeof null

在 JS 底層的實作裡面,有一個東西叫做 type tag(用來標記一個變數是什麼 type)

給 object 的 type tag 是 0

null 會被表示成一個 NULL pointer,type tag 是 0x00
typeof 去檢視 null 時,type tag 0x00 就被當成是 0,因此會回傳 object

typeof 來檢查:一個變數是否有被宣告、且被賦予值

如果 a 都沒有被宣告和賦予值 ,typeof a 結果會是 undefined

console.log(typeof a); // undefined

所以,有時會用下面這樣來檢查:一個變數是否有被宣告、且被賦予值

  • 如果沒有被宣告和賦予值,就不會發生任何事情
  • 如果有被宣告和賦予值,才印出來
    if (typeof a !== "undefined") {
    console.log(a);
    }
    

為什麼要用 typeof 來檢查,不能直接寫 if (a !== "undefined") 嗎?

如果是直接寫 if (a !== "undefined")

  • a 有被賦予值時,是 ok 的,沒有問題
var a = 10;
if (a !== "undefined") {
  console.log(a);
}
// output: 10
  • 可是當 a 沒有被宣告和賦予值時,程式就會整個出錯,會跳出「ReferenceError: a is not defined」,造成程式無法執行下去
    if (a !== "undefined") {
    console.log(a);
    }
    

Array.isArray() 判斷是否為 array

因為 typeof [] 結果會是 object
如果想要知道一個變數是不是 array 的話,可以用 Array.isArray()

注意:在比較舊的瀏覽器,沒有 Array.isArray() 這個方法可以用

Object.prototype.toString.call() 可以準確判斷出型態

第一個參數傳:我要檢測的東西

console.log(Object.prototype.toString.call("hello")); // [object String]

primitive type 是 immutable

primitive type 和 object 最大的差別就是:primitive type 是 immutable(不能改變的)

範例一:primitive type 是 immutable

下面這樣不叫做「改變」,叫做「重新賦值」

var a = 10;
a = 20;

範例二:primitive type 是 immutable

str.toUpperCase() 會回傳一個新的字串,而不是去改變 str 自己
所以 console.log(str) 的結果並不會變成大寫

var str = "hello";
str.toUpperCase();

console.log(str); // output: hello

所以,必須要用一個新的變數 newStr 去接收新的字串:

var str = "hello";
var newStr = str.toUpperCase();

console.log(str, newStr);
// output: hello HELLO

範例三:object 是可以改變的

arr.push(2) 可以改變原本的 arr

var arr = [1];
arr.push(2);

console.log(arr);
// output: [ 1, 2 ]

讓你摸不透的 = 賦值

primitive type 和 object,對於「賦值」的反應是不一樣的

primitive type

var a = 10;
var b = a;
console.log(a, b); // 10 10

b = 200;
console.log(a, b); // 10 200

object

var obj = {
  number: 10,
};
var obj2 = obj;
console.log(obj, obj2); // { number: 10 } { number: 10 }

obj2.number = 20;
console.log(obj, obj2); // { number: 20 } { number: 20 }

obj2 的 number 改成 20,但是發現 objobj2 都變成 { number: 20 }

對於 primitive type,變數裡面存的是「值」

宣告變數 ab
a 的值(10)複製給 b

var a = 10;
var b = a;

在記憶體裡面,可以想成就是這樣存的:

a: 10
b: 10

當我把 b 的值改成 20:

var a = 10;
var b = a;
b = 20;

在記憶體裡面就會把 b 的值改成 20:

a: 10
b: 20

對於 object,變數裡面存的是「記憶體位置」

當我宣告變數 obj{ number: 10 } 這個物件時,{ number: 10 } 會被放到某個記憶體位置上(例如 0x01 這個位置),在變數 obj 裡面,存的會是 0x01 這個記憶體位置

當我寫 var obj2 = obj 時,就是把 obj 的值(0x01)複製給 obj2,所以 objobj2 裡面存的都是 0x01,是指向同一個記憶體位置(是同一個物件)

// 在 0x01 這個記憶體位置裡面
0x01: {
  number: 10
}

// 變數裡面存的東西
obj: 0x01
obj2: 0x01

var obj = {
  number: 10,
};
var obj2 = obj; // 把 obj 的值複製給 obj2
console.log(obj, obj2); // { number: 10 } { number: 10 }

obj2.number = 20;
console.log(obj, obj2); // { number: 20 } { number: 20 }

當我用 obj2.number = 20 時,意思是:我要去改變「obj2 存的記憶體位置 0x01 裡面的 object 底下的 number 屬性,把 value 改成 20」

所以, objobj2 都會是 { number: 20 }

// 在 0x01 這個記憶體位置裡面
0x01: {
  number: 20
}

如果是用 = 重新賦值,就會是另一個新的記憶體位置了

舉例來說
當我宣告 arr = [] 時,背後做的事情是:

  • 先給 [] 一個記憶體位置 0x10
  • 再把 0x10 存到變數 arr 裡面

當我寫 var arr2 = arr 時,就是把 arr 的值複製給 arr2,所以 arrarr2 裡面存的都是 0x10,是指向同一個記憶體位置(是同一個陣列)

// 在 0x10 這個記憶體位置裡面
0x10: []

// 變數裡面存的東西
arr: 0x10
arr2: 0x10

var arr = [];
var arr2 = arr;
console.log(arr, arr2); // [], []

但是,當我寫 arr2 = ["arr2"] 時,因為是用 = 賦值,所以背後做的一樣會是:

  • 先給 ['arr2'] 一個新的記憶體位置 0x20
  • 再把 arr2 裡面存的東西,改成 0x20 這個新的記憶體位置

這時,arrarr2 已經指向不同的記憶體位置了,因此不會互相影響

// 在 0x10 這個記憶體位置裡面
0x10: []
0x20: ['arr2']

// 變數裡面存的東西
arr: 0x10
arr2: 0x20

var arr = [];
var arr2 = arr;
console.log(arr, arr2); // [], []

arr2 = ["arr2"];
console.log(arr, arr2); // [], ['arr2']

在 JS,當我對 object 用 = 賦值時,做的事情是:

  • 先給這個 object 一塊新的記憶體位置
  • 把這個記憶體位置存到變數裡面

如果不小心在 if 裡面寫了 =

var a = 10;
if ((a = 20)) {
  console.log(123);
}
// output: 123

if 裡面應該要用 == 或是 === 才對
如果不小心在 if 裡面寫了 =,是重新賦值的意思,if ((a = 20)) 的執行流程就會變成是:

  • 先執行 a = 20
  • 再去判斷 a 是否為 true
a = 20;
if (a) {
  console.log(123);
}

因此,會印出 123

== 與 === 的差別

差別在於:== 會按照一個規則去轉換型態,=== 不會轉換型態

== 會轉換型態,所以 console.log(2 == '2') 是 true
=== 不會轉換型態,所以 console.log(2 === '2') 是 false

=== 比較兩個 primitive type 時,比較的是「兩個變數的值」

=== 比較兩個 object 時,只有在「兩個 object 指向同一個記憶體位置」時,=== 才會是 true

console.log([] === []); // 兩個陣列是在「不同的記憶體位置」,所以是 false
console.log({} === {}); // 兩個物件是在「不同的記憶體位置」,所以是 false

範例一:比較物件

因為 object 裡面存的是記憶體位置,所以 obj === obj2 比較的是「變數裡面存的記憶體位置」,因為記憶體位置都是 0x01,所以會是 true

// 在 0x01 這個記憶體位置裡面
0x01: {
  number: 1,
};

// 在 object 裡面存的是記憶體位置
obj: 0x01
obj2: 0x01

var obj = {
  number: 1,
};

var obj2 = obj;
obj2.number = 2;

console.log(obj === obj2); // true

範例二:陣列

因為 arrarr2 裡面存的記憶體位置不同,所以儘管陣列的值一樣,arr === arr2 還是 false

// 在記憶體位置裡面
0x01: [1];
0x02: [1];

// 在 object 裡面存的是記憶體位置
arr: 0x01
arr2: 0x02

var arr = [1];
var arr2 = [1];
console.log(arr === arr2); // false

edge case: NaN

當我用 Number() 把一個不是數字的東西要轉成數字時,a 就會是 NaN

這是在使用 ===== 時的唯一特殊案例:NaN 不會等於任何東西(甚至是連自己本身都不等於)

因為一個「不是數字的東西」無法跟另一個不是數字的東西做比較

var a = Number("hello");
console.log(a); // NaN
console.log(a === a); // false
console.log(NaN === NaN); // false

isNaN() 這個 function 來檢視是不是 NaN

let 與 const

下面這樣寫會跳出錯誤「SyntaxError: Missing initializer in const declaration」
因為用 const 宣告變數時,一開始就要給初始值

const b;
b = 20;

變數的生存範圍:Scope

ES6 以前的作用域

Scope(作用域),就是「變數的生存範圍」

區域變數

範例一:

在 ES6 以前,作用域的基本單位是「function」,只有 function 能產生一個新的作用域

在上面的例子中,function test() 產生了一個 test scope,只有在這個 test scope 裡面,變數 a 才會被看到

變數 a 是區域變數

function test() {
  var a = 10;
  console.log(a); // 10
}

test();
console.log(a); // a is not defined

全域變數

在最外層宣告的,叫做 global variable(全域變數)
全域變數的作用域是以「檔案」為單位,意思是:一旦宣告後,整個檔案的任何位置都看得到這個變數

變數 a 是全域變數
作用域是「往上層找」

  • 第三行的 console.log(a),JS 的引擎會先在 function test() 的作用域裡面找「是否有宣告變數 a」,沒有找到的話,就會繼續往上層找,就找到了 global 這層的變數 a
var a = 20;
function test() {
  console.log(a); // 20
}

test();
console.log(a); // 20

區域變數和全域變數不會互相影響

範例一:

因為 function test 自己就有宣告變數 a 了,所以在 test scope 裡面用的 a 是「只存活在 test scope 的區域變數」,跟外面的全域變數 a 沒有任何關係

第五行把區域變數的 a 的值改成 30,並不會影響到全域變數的 a

var a = 1;
function test() {
  var a = 8; // 這個 a 是在 test scope 裡面的區域變數
  console.log(a); // 8
  a = 30; // 改變區域變數的 a 的值,並不會影響到全域變數的 a
  console.log(a); // 30
}

test();
console.log(a); // 1 (這是全域變數的 a)

範例二:

因為在 function test 裡面沒有宣告過 a,所以第三行的 a = 8 會往上層找到全域變數 a,把全域變數 a 的值改成 8

在 test scope 裡面的 a 就是「全域變數的 a

var a = 1;
function test() {
  a = 8; // 這個 a 會是全域變數的 a,把值改成 8
  console.log(a); // 8
}

test();
console.log(a); // 8 (全域變數 a 的值已經變成 8 了)

範例三:
第四行的 console.log(a),會先在自己的作用域(test scope)裡面找,所以會找到 var a = 10
第八行的 console.log(a),會先在自己的作用域(global)裡面找,所以會找到 var a = 20

var a = 20;
function test() {
  var a = 10;
  console.log(a); // 10 (這個 a 是在 test scope 的區域變數)
}

test();
console.log(a); // 20 (這個 a 是在 global scope 的全域變數)

自動在 global 幫我宣告一個變數

如果 a = 10 在前面都沒有被宣告過,就會自動在 global 幫我宣告一個變數 a
注意,下面這樣是不好的寫法,因為原本只想在 function 裡面宣告的變數,變成全域變數了(全域變數太多,可能會跟其他變數衝突到)

function test() {
  a = 10;
  console.log(a); // 10
}

test();
console.log(a); // 10

就等同於是這樣寫:

var a; // 在 global 幫我宣告一個變數 a
function test() {
  a = 10;
  console.log(a); // 10
}

test();
console.log(a); // 10

scope chain 就是「一直往上層 scope 找」的過程

下面範例的 scope chain(作用域鏈)就是:inner scope -> test scope -> global scope

會從自己的作用域開始找,找不到的話就一直往上一層找

var a = "global"; // global scope
function test() {
  // test scope
  var a = "test scope a";
  var b = "test scope b";
  console.log(a, b); // test scope a test scope b
  function inner() {
    // inner scope
    var b = "inner scope b";
    console.log(a, b); // test scope a inner scope b
  }
  inner();
}

test();
console.log(a); // global

作用域的判斷

錯誤的觀念:

因為我在 function change 裡面呼叫 test(),所以在 function test 裡面的 a 會去找 function change 裡面宣告的 a
下面這是錯誤的 scope chain

test scope -> change scope -> global scope

正確的觀念:

關於作用域的判斷,跟「在哪裡呼叫 function」一點關係都沒有,而是用「在哪裡宣告 function」來判斷。在宣告 function 的當下,scope chain 就已經定義好了,不管在哪裡呼叫這個 function,scope chain 都不會改變

  • change scope 和 test scope 是兩個平行的 scope,也就是說:
  • change scope 的上一層就是 global scope
  • test scope 的上一層也是 global scope,而不是 change scope

下面這是正確的 scope chain(永遠不會改變)

change scope -> global scope
test scope -> global scope

所以,下面 console.log(a) 會去找「在 global scope 的 a

var a = "global"; // global scope

function change() {
  // change scope 的上一層就是 global scope
  var a = 10;
  test();
}

function test() {
  // test scope 的上一層就是 global scope
  console.log(a); // global
}

change();

注意,在 function change 裡面呼叫 test(),並不等於下面這樣:
下面這樣叫做「在 function change 裡面宣告 function test」,這時,test scope 的上一層就會是 change scope 了

function change() {
  // change scope
  var a = 10;
  function test() {
    console.log(a);
  }
}

如果把 function test 放在 function change 裡面宣告,那不管我在哪裡呼叫 test()function test 的 scope chain 都會是:test -> change -> global

var a = "global"; // global scope

function change() {
  // change scope
  var a = "change";

  function inner() {
    var a = "inner";
    test();
  }

  function test() {
    // scope chain 會是:test -> change -> global
    console.log(a);
  }
  inner();
}

change();

let 與 const 的生存範圍

var 宣告的變數,作用域的範圍是「一個 function」

例如:
function test 裡面,都是變數 b 的作用域

function test() {
  var a = 60;
  if (a === 60) {
    var b = 10; // b 的作用域是「一整個 function test」
  }
  console.log(b); // 10
}

test();

letconst 宣告的變數,作用域的範圍是「一個 block」

例如:

function test() {
  var a = 60;
  if (a === 60) {
    let b = 10; // b 的作用域只有在「if 這個 block」
  }
  console.log(b); // b is not defined
}

test();

#javascript







Related Posts

理解 JavaScript 中物件的比大小行為

理解 JavaScript 中物件的比大小行為

Day 1 Markdown & Minimal Table

Day 1 Markdown & Minimal Table

MTR04_0923

MTR04_0923


Comments