你懂 JavaScript 嗎?#3 暖身 (๑•̀ㅂ•́)و✧ Part 2 - 變數、嚴格模式、IIFEs、閉包、模組、this、原型、Polyfill 與 Transpiler

你所不知道的 JS

上一篇暖身文章中大致聊過了一些基本知識,像是運算子、運算式、值與型別、變數、條件式、迴圈,本文還會再探討一些基礎概念,像是

本文也僅是概念而已,之後會有單篇章節細細討論的,所以就算是暖身 Part 2 摟。

開始吧!

開始吧!

變數(Variable)

這個部份要來談關於變數的存取規則,例如:範疇、拉升等。

函式範疇(Function Scope)

函式會建立自己的範疇,其內的識別字(不管是變數、函式)僅能在這個函式裡面使用。

如下,在全域範疇底下,是無法存取 foo 內的 a、b、c 和 bar,否則會導致 ReferrenceError;但在 foo 自己的函式範疇內,可以存取 a、b、c 和 bar。

function foo(a) {
  var b = 2;

  function bar() {
    // ...
  }

  var c = 3;
}

console.log(a); // ReferrenceError
console.log(b); // ReferrenceError
console.log(c); // ReferrenceError

bar(); // ReferrenceError

拉升(Hoisting)

在程式執行前,編譯器(compiler)會先由上到下逐行將程式碼轉為電腦可懂的命令,然後再執行編譯後的指令。在這個編譯的階段,編譯器找出所有的變數並繫結所屬範疇,但不賦值,所以此刻變數所帶的值是 undefined;而在執行階段,JavaScript 引擎才會處理給值的事情。

我們可以把這個過程想像成是將這些變數「提升」到程式碼的最頂端,如下範例所示,因此當印出 a 的值的時候,會是已宣告但還沒賦值的狀態,也就是有這個變數,但其值是 undefined,一直到程式執行了,才給值。因此,我們可以在程式碼任何地方呼叫運用這個變數,但只有在正式宣告之後才能有正確的值可用,在宣告之前使用都會得到 undefined。

var a; // 編譯時期的工作

console.log(a); // undefined
a = 2; // 執行時期的工作

巢狀範疇(Nested Scope)

若在目前執行的範疇找不到這個變數的時候,就會往外層的範疇搜尋,持續搜尋直到找到為止,或直到最外層的全域範疇(global scope,在瀏覽器底下就是指 window)。

如下,console.log(a + b) 中,b 無法在 foo 中找到,但可從全域範疇中追出來。

const foo = (a) => {
  console.log(a + b);
};

const b = 2;

foo(2); // 4

相較於巢狀範疇是以函式為劃分單位,區塊範疇就是以大括號為界線了。

嚴格模式(Strict Mode)

嚴格模式簡單說就是為了預防開發者的一些不小心或錯誤的行為,JavaScript 引擎協助做了一些檢測的工作,當開發者誤用時就把錯誤丟出來。可參考-MDN

範例如下,在未宣告變數而賦值的狀況下,會無預警的產生一個全域變數,但若使用嚴格模式('use strict')則會禁止這行為外,還會報錯,告知開發者變數尚未被定義。

'use strict';

a = 1; // Uncaught ReferenceError: a is not defined

就把它想像成是一個諄諄教誨的好老師吧!總是願意告訴你殘忍的實話…

好老師

作為值的函式(Function as Value)

這標題看起來有點怪怪的(?)但其實也只是要說明,函式本身就和其他的值一樣,是可以被指定給某個變數、當參數傳遞或當成其他函式的回傳值。記得,函式也只是物件的子型別而已,沒什麼特別的。

指定給某個變數,如下,指定給 foo。

var foo = function () {
  console.log('大家好,我是 foo!');
};

當參數傳遞,如下,將 foo 當成是 bar 的參數傳入。

var foo = function () {
  console.log('大家好,我是 foo!');
};

function bar(func) {
  func();
}

bar(foo); // 大家好,我是 foo!

當成其他函式的回傳值,foo 是 baz 的回傳值,並將結果指定給 result。

var foo = function () {
  console.log('大家好,我是 foo!');
};

var result = function baz(func) {
  return func;
};

result(foo)(); // 大家好,我是 foo!

因此,這個函式值(例如:var foo = function() { ... })也可被視為是一個運算式,就稱呼它為「函式運算式」吧。之後還會提到函式宣告、函式運算式與匿名 vs 具名,待後續更詳細的說明。

期待

即刻調用函式運算式(Immediately Invoked Function Expression, IIFE)

IIFE 是為可立即執行的函式運算式。一般的函式運算式並不會馬上執行,若要執行除了在其名稱後加上小括號外,還可以利用 IIFE 的方式執行它,匿名或具名皆合法。使用 IIFE 的好處主要是不污染全域範疇。

範例如下,這是一個匿名的 IIFE,a 在全域範疇是找不到的。

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

// 不污染全域範疇
a; // Uncaught ReferenceError: a is not defined

閉包(Closure)

閉包是指變數的生命週期只存在於該函式內,一旦離開了函式,該變數就會被回收而不可再利用,且必須在函式內事先宣告。

範例如下,在函式 closure 內可以存取 a 的值,但離開了函式 closure 走到全域範疇之下,就取不到 a 的值了,因此會被報錯「Uncaught ReferenceError: a is not defined」。

function closure() {
  var a = 1;
  console.log(a); // 1
}

closure();
a; // Uncaught ReferenceError: a is not defined

模組(Module)

模組模式(Module Pattern)又稱為揭露模組(Revealing Module),經由建立一個模組實體(Module Instance,如下範例的 foo),來調用內層函式。而內層函式由於具有閉包的特性,因此可存取外層包含函式(Outer Enclosing Function)之內的變數和函式。透過模組模式,可隱藏私密的資訊,並對外公開 API。

範例如下,CoolModule 對外公開 API doSomething 和 doAnother,CoolModule 之外是無法取得其私有的 something 和 another 兩個變數的值。

function CoolModule() {
  var something = 'cool';
  var another = [1, 2, 3];

  function doSomething() {
    console.log(something);
  }

  function doAnother() {
    console.log(another.join(' ! '));
  }

  return {
    doSomething: doSomething,
    doAnother: doAnother,
  };
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

this 識別字(this Identifier)

this 到底是指向誰一直都是個令人費解的問題。

what is this?

圖片來源:What is this meaning of this?

簡單來說,this 是 function 執行時所屬的物件,而 this 是在執行時期做繫結,其值和函式在哪裡被呼叫(call-site)有關。

總結規則如下,並以匹配的優先順序由高至低排列

範例如下。

function foo() {
  console.log(this.bar);
}

var bar = 'global';

var obj1 = {
  bar: 'obj1',
  foo: foo,
};

var obj2 = {
  bar: 'obj2',
};

foo(); // 'global'
obj1.foo(); // 'obj1'
foo.call(obj2); // 'obj2'
new foo(); // undefined

原型(Prototype)

原型可說是物件的一種 fallback 機制,當在此物件找不到指定屬性時,就會透過原型鏈結(prototype link / prototype reference)追溯到其父物件上。範例如下,若想存取 bar.a 但由於 bar 並無 a 屬性,因此就會透過原型鏈結找到 foo,並得到 100 這個值。

var foo = { a: 100 };

var bar = Object.create(foo); // 建立 bar 物件,並連結到 foo
bar.b = 'hi';

bar.a; // 100,委派給 foo
bar.b; // 'hi'

原型

另外,原型最常應用於「行為委派」(behavior delegation),如上例所示,將物件 bar 的行為委派給 foo,這也是常聽到很類似於其他語言的類別的繼承功能,但其實完全不同。

舊功能與新特色的共存

面對新舊功能並存的狀況要怎麼處理呢?這裡要介紹兩種方法-PolyfillTranspiler

Polyfill

Polyfilling 的意思就是依據一個新功能的定義,製作具有相同行為,而能在較舊的 JavaScript 環境執行的程式碼,白話說就是為舊瀏覽器掛載新功能。

這裡來看一個例子,針對 isNaN 的改進…

isNaN

NaN 表示值為無效的數字,它會產生的原因是

以上就會產生 NaN。

在 ES6 以前,開發者使用 isNaN 在數學運算或解析字串後檢測得到的結果是否為合法的數字,其實就是檢測是否為 NaN,其過程為先將輸入值使用 Number 強制轉型為數字,若無法轉為有效的數字而得到 NaN 時就判定等於 NaN,結果得到 true。

範例如下,空物件 {} 經過 isNaN 判斷是 NaN,意即不為數字。

isNaN({}); // true,不是數字

// 拆解詳細過程如下...
Number({}); // 先將空物件轉為數字,得到 NaN
isNaN(NaN); // 檢查是否為 NaN,得到 true

其他範例還有…

isNaN(123); // false
isNaN(-1.23); // false
isNaN(5 - 2); // false
isNaN(0); // false
isNaN('123'); // false
isNaN('Hello World'); // true
isNaN('2000/01/01'); // true
isNaN(''); // false
isNaN(true); // false
isNaN(undefined); // true
isNaN('NaN'); // true
isNaN(NaN); // true
isNaN(0 / 0); // true
isNaN(1 / 0); // false

但這檢測方式的常常會讓開發者得到讓人容易誤解的結果(像是空物件 {} 就真的不等於 NaN 呀),因此 ES6 推出了 Number.isNaNNumber.isNaN 不會經過轉為數字的這個過程,而是直接判斷型別是否為數字且是否等於 NaN。承上範例,檢測空物件 {} 是否為 NaN,得到 false。

Number.isNaN({}); // 直接檢查空物件是否為 NaN,得到 false

同樣也來看剛才的範例…

Number.isNaN(123); // false
Number.isNaN(-1.23); // false
Number.isNaN(5 - 2); // false
Number.isNaN(0); // false
Number.isNaN('123'); // false
Number.isNaN('Hello World'); // false
Number.isNaN('2000/01/01'); // false
Number.isNaN(''); // false
Number.isNaN(true); // false
Number.isNaN(undefined); // false
Number.isNaN('NaN'); // false
Number.isNaN(NaN); // true
Number.isNaN(0 / 0); // true
Number.isNaN(1 / 0); // false

雖然 ES6 出了這個新功能,但不見得所有的瀏覽器都會支援,因此對於較舊瀏覽器,就掛個 polyfill 來模擬這個新功能。

polyfill 如下。

if (!Number.isNaN) {
  Number.isNaN = function isNaN(x) {
    return x !== x; // NaN 是唯一一個不等於自己的值
  };
}

ES6 定義了常數 Number.NaN 來表示 NaN。

isNaN(NaN); // true
isNaN(Number.NaN); // true

Number.isNaN(Number.NaN); // true
Number.isNaN(NaN); // true

由於實作 polyfill 難免會有缺漏或疏失,這裡提供兩個經過嚴格審核的函式庫以供使用-es5-shimes6-shim

Transpiler

並非所有的新功能都能經由 polyfill 掛載到舊環境上,這裡提出另一個解法-將帶有新功能的程式碼轉換成等效的舊程式碼就可以了,也就是使用 transpiler 做轉譯。

例如,ES6 推出了新的功能「預設參數值」。

function foo(a = 2) {
  console.log(a);
}

foo(); // 2
foo(42); // 42

但這在舊的 JavaScript 引擎中是無效的,因此 transpiler 就會將以上程式碼變形、翻譯成等效的舊程式碼。

function foo() {
  var a = arguments[0] !== void 0 ? arguments[0] : 2;
  console.log(a);
}

這麼做的好處是在開發階段開發者依然能享受新功能帶來的好處,但又能兼顧到新舊瀏覽器的狀況。這裡也推薦一些很棒的 transpiler,像是 BabelTraceur 等。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

References


暖身結束,接下來就要進入正題了,明天見!

Pusheen


同步發表於2019 鐵人賽


You-Dont-Know-JS javascript prototype javascript closure 閉包 javascript 2019鐵人賽 你所不知道的JS 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-Up-and-Going ReferenceError undefined NaN 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文