🥜 前情提要

上回我們解釋了同步和異步是什麼,而這回解釋作用域Scope的問題。

Scope:

JavaScript中Scope 是個必修的問題,在開發中一定會遇到的問題,首先介紹this:

this:

首先我們回到C系列,今天我要讓我的全域變數snm = 函數傳入的一個數字參數,函數的參數叫做sum,請問我該如何處理? 答案:使用this。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Program 
{
private int sum = 20;

private void Main() {
test(10);
}

private void test(int sum) {
this.sum = sum;
Console.WriteLine(this.sum);
}
}

上述可以得知this功能是指定我現在的作用域來源區域,以此Case是代表 Program Class。

在JavaScript中,因為它是由引擎所解釋

  • 在瀏覽器中,一個分頁內所有JavaScript的操作都是在一個物件內執行,我們稱為 window。
  • 在Nodejs中,所有的JavaScript的操作都是在一個物件內執行,我們稱為 global

所有的JavaScript 的API都是放在這個物件內。以下我們假定runtime在瀏覽器。

而JavaScript中,沒有上層物件的話,this 就是指 window,舉例:

1
2
3
4
5
6
7
8
9
10
11
12
13
function tellMeIsInWindow() {
var msg = this == window ? "Yes" : "No" ;
console.log("當前作用域是否在Window內 = " + msg);
}

function showTemp() {
console.log("當前this的temp = " + this.temp);
console.log("當前window的temp = " + window.temp);
}

var temp = 20;
tellMeIsInWindow();
showTemp();

而上層有物件的話,this指向將會指到該物件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function tellMeIsInWindow() {
var msg = this == window ? "Yes" : "No" ;
console.log("當前作用域是否在Window內 = " + msg);
}

function showTemp() {
console.log("當前this的temp = " + this.temp);
console.log("當前window的temp = " + window.temp);
}

var obj = {};
obj.temp = 50;
obj.obj_TellMe = tellMeIsInWindow;
obj.obj_showTemp = showTemp;

obj.obj_TellMe(); //
obj.obj_showTemp();

所以我們釐清JavaScript的this操作規則:

在沒有上層物件內 this == window,而在有上層物件內this == 上層物件,this 是相對的不是絕對的。

this 操作:

這邊我們用更多的操作來說明this的操作情境:

1. 物件的屬性物件的函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var getName = function() {
return this.name;
};

var player = {
name: 'OneJar',
f: getName,
pet: {
name: 'Totoro',
f: getName,
}
};

console.log(player.f()); // "OneJar"
console.log(player.pet.f()); // "Totoro"

執行結果:

1
2
OneJar
Totoro

分析:

  • 輸出OneJar 因為上層物件是 player,所以輸出對象是player的name
  • 輸出Totoro 因為上層物件是 pet ,所以根據規則應該是輸出 Totoro

2. 內部函數:

1
2
3
4
5
6
7
8
9
10
11
var x = 10;
var obj = {
x: 20,
f: function(){
console.log('Output 1: ', this.x);
var foo = function(){ console.log('Output 2: ', this.x); }
foo();
}
};

obj.f();

執行結果:

1
2
Output 1:  20
Output 2: 10

分析:

  • 輸出20 因為上層物件是 obj,所以輸出對象是 obj.x
  • 輸出10 因為foo它是由obj的f函數所呼叫,所以他沒有上層物件,這時後就會是window,就是10

3. HTML 事件處理:

onclick 裡的 this,指的就是 button 元素本身

1
2
3
<button onclick="console.log(this);">
Click to Remove Me!
</button>

但是如果是

執行結果:

1
20

其實就是 內部函數 的變形,點下時後執行onclick,onclike又執行temp函數,所以就是內部函數。

4. bind方法:

ES5 導入了 Function.prototype.bind,可以為一個函數進行綁定參數,並且無法覆蓋已綁定的物件,只能進行擴充

  • bind的第一個參數是綁定該函數的擁有物件(擁有者)。
  • bind的第二個參數是綁定該函數的第一個參數。
  • bind的第三個參數是綁定該函數的第二個參數。

換句話說,無論新的函數物件怎麼被呼叫,只要函數被bind一次,其函數內的 this 都會是當初綁定的那個擁有者物件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var getFullName = function(lastName) {
return this.firstName + " " + lastName;
}

var firstName = "One"
var lastName = "Jar";

var introIronMan = getFullName.bind( { firstName: "Tony" } , "Stark" );
var introCaptainAmerica = getFullName.bind( { firstName: "Steven" } , "Rogers" );
var introThanos = introIronMan.bind( { firstName: "IDonT" } , "Know" );

console.log(getFullName(lastName)); // "One Jar"
console.log(introIronMan()); // "Tony Stark"
console.log(introCaptainAmerica()); // "Steven Rogers"
console.log(introThanos()); // "Tony Stark" bind函數只能作用第一次,第二次綁定只是多塞傳入參數

5. call方法:

ES5 導入了 Function.prototype.call,可以為一個函數進行傳入參數

  • call的第一個參數是綁定該函數的擁有物件(擁有者)。
  • call的第二個參數是綁定該函數的第一個參數。
  • call的第三個參數是綁定該函數的第二個參數。
1
2
3
4
5
6
7
8
9
var getFullName = function (lastName) {
return this.firstName + " " + lastName;
}

var IronMan = { firstName: "Tony" };
var CaptainAmerica = { firstName: "Steven" }

console.log(getFullName.call(IronMan, "Stark")) // "Tony Stark"
console.log(getFullName.call(CaptainAmerica, "Rogers")) // "Steven Rogers"

6. apply方法:

ES5 導入了 Function.prototype.apply,可以為一個函數進行傳入參數

  • call的第一個參數是綁定該函數的擁有物件(擁有者)。
  • call的第二個陣列參數是綁定該函數的所有參數。
1
2
3
4
5
6
7
var getFullName = function (title, lastName, msg) {
return title + this.firstName + " " + lastName + " " + msg;
}

var IronMan = { firstName: "Tony" };
var applyAry = ["[Lucas]", "Stark", "I Love You"]
console.log(getFullName.apply(IronMan, applyAry)) // [Lucas]Tony Stark I Love You

7.CallBack函數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var name = "Hi I am Global";

function sayHi(){
return this.name;
}

var hero = {
name: "Hi I am a Hero",
act: function(cbk){
return cbk();
}
};

console.log( sayHi() ); // Hi I am Global
console.log( hero.act(sayHi) ); // Hi I am Global

分析:

  • 輸出第一次 Hi I am Global 也就是 sayHi() 很明顯沒問題
  • 輸出第二次 Hi I am Global 也就是 hero.act(sayHi),對於cbk作用域的上一層是Hero,但是他又執行了一次cbk,這又是典型的內部函數

this 總結:

this在網頁中有兩種執行邏輯

  1. JavaScript
  2. Html

而Html的邏輯不需要了解,沒人使用。所以此處都預設為JavaScript邏輯。

在沒有上層物件內 this == window,而在有上層物件內this == 上層物件,this 是相對的不是絕對的。

變數:

我們來看默認操作的Scope於JavaScript的影響,一樣這邊先使用C# 進行解說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace WindowsFormsApp3
{
class sum_0 {}
class sum_1 {}
class sum_2 {}
class Program
{
private string sum_0;
private string sum_1 = "";

private void Main() {
test(10);
}
private void test(int sum_0) {
Console.WriteLine(sum_0.GetType()); // int
Console.WriteLine(sum_1.GetType()); // string
Console.WriteLine(sum_2.GetType()); // 會報錯,因為型別為Class無法GetType
}
}
}

根據上述Case,我們可以得出當你沒使用this來操作變數 sum,系統會依照這個優先順序來給你數值:

C#: 當前作用域(函數)的sum > 上層作用域(Class Program)的sum > 上上層作用域(namespace)的sum

那麼JavaScript的規則也是如此嗎?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var sum = 50;

function test_1() { console.log(sum) }
function test_2(sum) { console.log(sum) }
function test_3() { console.log(this.sum) }

var obj = {};
obj.sum = 20;
obj.demo_1 = test_1;
obj.demo_2 = test_2;
obj.demo_3 = test_3;

obj.demo_1(); // 輸出 50
obj.demo_2(9527); // 輸出 9527
obj.demo_3(); // 輸出 20

根據上述Case,我們可以得出當你沒使用this來操作變數 sum,系統會依照這個優先順序來給你數值:

JavaScript: 當前作用域(函數)的sum > window 的sum。


看起來分為兩層,但事實卻是自從ES6後是分為三層,ES6加入let宣告方式,改變了層級定義,新增Block 層級。首先介紹一下層級:

  • Global Level Scope – 國際巨星阿湯哥
  • Function Level Scope – 香港喜劇天王星爺
  • Block Level Scope – 住在隔壁號稱歌神的里長阿伯

以下我們舉個例子:

變數 操作:

1. 宣告在 Function 內 (使用 var、let):

1
2
3
4
5
6
7
function myFunc(){
var n1 = "OneJar";
console.log("myFunc(): typeof n1=", typeof n1, " value=", n1);
}

myFunc();
console.log("Global: typeof n1=", typeof n1);

執行結果:

1
2
myFunc(): typeof n1= string  value= OneJar
Global: typeof n1= undefined

作用域範圍圖:

紅色是 n1 宣告的地方,淺藍色部分就是 n1 的 Scope。

  • 基本 Function Scope
  • 只有在自己這個 function 內有效,包含 function 內的子 Block
  • 別的 function 不認得,主程式區也不認得。

2. 宣告在主程式區 (使用 var):

1
2
3
4
5
6
7
8
function myFunc(){
console.log("myFunc(): this.n1=", this.n1);
console.log("myFunc(): window.n1=", window.n1);
}

var n1 = "OneJar";
myFunc();
console.log("Global: n1=", n1);

執行結果:

1
2
3
myFunc(): this.n1= OneJar
myFunc(): window.n1= OneJar
Global: n1= OneJar

作用域範圍圖:

紅色是 n1 宣告的地方,淺藍色部分就是 n1 的 Scope。

  • 基本 Global Scope。
  • 主程式區內的所有子 Block 和函數都認得。

3. 宣告在主程式區 (使用 let):

1
2
3
4
5
6
7
8
function myFunc(){
console.log("myFunc(): this.n1=", this.n1);
console.log("myFunc(): window.n1=", window.n1);
}

let n1 = "OneJar";
myFunc();
console.log("Main: n1=", n1);

執行結果:

1
2
3
myFunc(): this.n1= undefined
myFunc(): window.n1= undefined
Main: n1= OneJar

作用域範圍圖:

  • 在執行時,主程式區因為let存在會被包裝成一個 Function 去執行 (圖中隱藏的 Main())。
  • 所以變數 n1 不會成為 Global Scope,而是 Function Scope / Block Scope。

4. 賦值給未宣告的變數,會自動產生的全域變數:

1
2
3
4
5
6
7
8
9
function myFunc(){
n1 = "OneJar"; // 自動變成一個 Global 變數
console.log("myFunc(): n1=", n1);
console.log("myFunc(): this.n1=", this.n1);
console.log("myFunc(): window.n1=", window.n1);
}

myFunc();
console.log("Global: n1=", n1);

執行結果:

1
2
3
4
myFunc(): n1= OneJar
myFunc(): this.n1= OneJar
myFunc(): window.n1= OneJar
Global: n1= OneJar

作用域範圍圖:

  • 紫色代表 n1 = “OneJar”,也就是沒有宣告就對 n1 賦值的地方。
  • 雖然賦值的地方是在 function 內,但因為沒有先宣告,JavaScript 的行為會自動將 n1 升級為 Global 變數,所以變成 Global Scope。

5. Global 和 Function 內同時存在同名變數:

1
2
3
4
5
6
7
8
9
10
function myFunc(){
var n1 = "Stephen Chow";
console.log("myFunc(): n1=", n1);
console.log("myFunc(): this.n1=", this.n1);
console.log("myFunc(): window.n1=", window.n1);
}

var n1 = "Tom Cruise";
myFunc();
console.log("Global: n1=", n1);

執行結果:

1
2
3
4
myFunc(): n1= Stephen Chow
myFunc(): this.n1= Tom Cruise
myFunc(): window.n1= Tom Cruise
Global: n1= Tom Cruise

作用域範圍圖:

  • 紅色是 var n1 = “Tom Cruise”,宣告在主程式區,屬於 Global Scope。
  • 綠色是 var n1 = “Stephen Chow”,宣告在主程式區,屬於 Function Scope。
  • 淺藍色區域,會生效的是紅色的 n1
  • 黃色區域,會生效的是綠色的 n1

6. Block 內使用 var 宣告:

1
2
3
4
5
6
7
8
if(true){
var x = 2;
{
console.log(x); // 2
}
console.log(x); // 2
}
console.log(x); // 2

執行結果:

1
2
3
2
2
2

作用域範圍圖:

  • 傳統 var 不支援 Block Scope。
  • 若是宣告在主程式區的 Block,會是 Global Scope (如上圖所示)。
  • 若是宣告在函數內的 Block 內 (例如 Block C 內),會是 Function Scope。

7. Block 內使用 let 宣告:

1
2
3
4
5
6
7
8
if(true){
let x = 2;
{
console.log(x); // 2
}
console.log(x); // 2
}
console.log(x); // ReferenceError: x is not defined

執行結果:

1
2
3
2
2
ReferenceError: x is not defined

作用域範圍圖:

  • 使用 let 或 const 宣告變數,支援 Block Scope 效果。
  • 變數 x 只會在被宣告的那個 Block 和其子 Block 被認得。

嚴格模式:

由上述可以知道JavaScript的語法很寬鬆,容易產生Bug,所以W3C定義了嚴格模式

只要你在主程式或函數的開頭加入 “use strict” 就會開啟嚴格模式。

1
2
3
"use strict";
x = 123; // ReferenceError: x is not defined
console.log(x);
1
2
3
4
5
6
7
"use strict";

function f1(){
return this;
}

console.log( f1() ); // undefined

如果是TypeScript可以在tsconfig.json開啟該模式。

變量、函數提升:


根據W3C的規範,他們說JavaScript需要變量提升和函數提升。我們來看例子:

1
console.log(x); 

執行結果:

1
Uncaught ReferenceError: x is not defined

很明顯你使用了沒宣告的變數,當然報錯,這是基本程式語言概念。

1
2
console.log(x);
var x = "OneJar";

執行結果:

1
undefined


其實背後的機制是變量提升,當上面寫法被變量提升後,它等同於下面這種寫法:

1
2
3
var x;
console.log(x);
x = "OneJar";

對於函數也是有提升效果:

1
2
3
4
5
sayHi();

function sayHi(){
console.log('Hi');
}

執行結果:

1
Hi

但是 當你使用let 或是 const(ES6跟let一起加入的宣告方式),就不具備提升效果,例子如下:

1
2
console.log(x);
let x = "OneJar";

執行結果:

1
Uncaught ReferenceError: x is not defined

汙染變數:

假設今天obj是個模組,對於使用者來說只要使用setMsg 和 showMsg就足夠,但是很明顯 msg 和 getMsg 暴露了,這樣會導致不熟悉模組的人亂操作,就會汙染變數或是函數,讓模組安全性降低。

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {};
obj.msg = "null";
obj.setMsg = function (newMsg) {this.msg = newMsg};
obj.getMsg = function () {return this.msg};
obj.showMsg = function () {console.log(this.getMsg())};

// ----------使用者場景----------
obj.setMsg("Lucas");
obj.showMsg();

// ----------汙染行為----------
obj.getMsg = function () { return "Fuck"};
obj.msg = "You";

或許會有人說 TypeScript的 private 和 Class 機制可以防止該問題,的確,但還是無法阻擋有心人:

1
2
3
4
5
6
7
8
9
10
11
12
13
class MsgManger
{
private msg: string = "null";
private getMsg(): string { return this.msg };

public setMsg(newMsg): void { this.msg = newMsg };
public showMsg(): void { console.log(this.getMsg()) };
}

var obj : MsgManger = new MsgManger();
obj.setMsg("Lucas");
obj["msg"] = "Fuck"; // 強制修改方法
obj.showMsg(); // 輸出為 Fuck

閉包:

針對這問題其中一個解決方法就是閉包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Closure() {
var msg = "Null";
function getMsg() { return msg };
return {
setMsg: function (newMsg) { msg = newMsg },
showMsg: function () { console.log(getMsg()) }
}
}
var obj = Closure()

obj.setMsg("Lucas");
obj.showMsg();

console.log(obj.msg + " test")

執行結果:

立即執行返回一個對象,主要利用 局部 Scope 和return來實現。