你懂 JavaScript 嗎?#3 暖身 (๑•̀ㅂ•́)و✧ Part 2 - 變數、嚴格模式、IIFEs、閉包、模組、this、原型、Polyfill 與 Transpiler
10 Oct 2018在上一篇暖身文章中大致聊過了一些基本知識,像是運算子、運算式、值與型別、變數、條件式、迴圈,本文還會再探討一些基礎概念,像是
- 變數的存取規則,包含函式範疇、拉升、巢狀範疇。
- 嚴格模式:一個讓程式碼變得更好、更容易優化的方法。
- 更多關於範疇和函式的應用,包含 IIFE、閉包、模組。
- this:到底是指哪個?這個還是那個?應該不少人都黑人問號 XD
- 原型可說是物件的一種 fallback 機制,並且提供了行為委派。
- 舊功能與新特色的共存,可使用 Polyfill 和 Transpiler 來做兼容。
本文也僅是概念而已,之後會有單篇章節細細討論的,所以就算是暖身 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 meaning of this?
簡單來說,this 是 function 執行時所屬的物件,而 this 是在執行時期做繫結,其值和函式在哪裡被呼叫(call-site)有關。
總結規則如下,並以匹配的優先順序由高至低排列
- new 綁定:this 會指向 new 出來的物件。
- 明確綁定:使用 call、apply、bind,明確指出要綁定給 this 的物件。
- 隱含綁定:當函式為物件的方法(method)時,在執行階段 this 就會被綁定至該物件。
- 預設綁定:當其他規則都不適用時,意即沒有使用 bind、call、apply 或不屬於任何物件的 method,就套用預設綁定,在非嚴格模式下,瀏覽器環境 this 的值就是預設值全域物件 window,而在嚴格模式下,this 的值就是 undefined。
範例如下。
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,這也是常聽到很類似於其他語言的類別的繼承功能,但其實完全不同。
舊功能與新特色的共存
面對新舊功能並存的狀況要怎麼處理呢?這裡要介紹兩種方法-Polyfill 和 Transpiler。
Polyfill
Polyfilling 的意思就是依據一個新功能的定義,製作具有相同行為,而能在較舊的 JavaScript 環境執行的程式碼,白話說就是為舊瀏覽器掛載新功能。
這裡來看一個例子,針對 isNaN 的改進…
isNaN
NaN 表示值為無效的數字,它會產生的原因是
- 做數學運算時的兩個運算元的資料型別並非都數字或無法轉成有效的十進位或十六進位的數字。
- 無意義的運算,例如:0 / 0、Infinity / Infinity。
以上就會產生 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.isNaN
,Number.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-shim 和 es6-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,像是 Babel、Traceur 等。
回顧
看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…
- 變數的存取規則,包含函式範疇、拉升、巢狀範疇。
- 嚴格模式:一個讓程式碼變得更好、更容易優化的方法。
- 更多關於範疇和函式的應用,例如:IIFE、閉包、模組。
- this 的值的判斷原則與範例。
- 原型可說是物件的一種 fallback 機制,並且提供了行為委派。
- 舊功能與新特色的共存,可使用 Polyfill 和 Transpiler 來做兼容。
References
暖身結束,接下來就要進入正題了,明天見!
同步發表於2019 鐵人賽。