引言
JS
系列暫定 27 篇,從基礎,到原型,到非同步,到設計模式,到架構模式等,此為第一篇:是對 var、let、const、解構、展開、函式 的總結。
let
在很多方面與 var
是相似的,但是 let
可以幫助大家避免在 JavaScript 裡常見一些問題。const
是對 let
的一個增強,它能阻止對一個變數再次賦值。
一、var
宣告
一直以來我們都是通過 var
關鍵字定義 JavaScript 變數。
var num = 1;
複製程式碼
定義了一個名為 num
值為 1
的變數。
我們也可以在函式內部定義變數:
function f() {
var message = "Hello, An!";
return message;
}
複製程式碼
並且我們也可以在其它函式內部訪問相同的變數。
function f() {
var num = 10;
return function g() {
var b = num + 1;
return b;
}
}
var g = f();
g(); // 11;
複製程式碼
上面的例子裡,g
可以獲取到 f
函式裡定義的 num
變數。 每當 g
被呼叫時,它都可以訪問到 f
裡的 num
變數。 即使當 g
在 f
已經執行完後才被呼叫,它仍然可以訪問及修改 num
。
function f() {
var num = 1;
num = 2;
var b = g();
num = 3;
return b;
function g() {
return num;
}
}
f(); // 2
複製程式碼
作用域規則
對於熟悉其它語言的人來說,var
宣告有些奇怪的作用域規則。 看下面的例子:
function f(init) {
if (init) {
var x = 10;
}
return x;
}
f(true); // 10
f(false); // undefined
複製程式碼
在這個例子中,變數 x
是定義在 if
語句裡面,但是我們卻可以在語句的外面訪問它。
這是因為 var
宣告可以在包含它的函式,模組,名稱空間或全域性作用域內部任何位置被訪問,包含它的程式碼塊對此沒有什麼影響。 有些人稱此為 var
作用域或函式作用域 。 函式引數也使用函式作用域。
這些作用域規則可能會引發一些錯誤。 其中之一就是,多次宣告同一個變數並不會報錯:
function sumArr(arrList) {
var sum = 0;
for (var i = 0; i < arrList.length; i++) {
var arr = arrList[i];
for (var i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
return sum;
}
複製程式碼
這裡很容易看出一些問題,裡層的 for
迴圈會覆蓋變數 i
,因為所有 i
都引用相同的函式作用域內的變數。 有經驗的開發者們很清楚,這些問題可能在程式碼審查時漏掉,引發無窮的麻煩。
捕獲變數怪異之處
快速的思考一下下面的程式碼會返回什麼:
for (var i = 0; i < 10; i++) {
setTimeout(function() { console.log(i); }, 100 * i);
}
複製程式碼
介紹一下,setTimeout
會在若干毫秒的延時後執行一個函式(等待其它程式碼執行完畢)。
好吧,看一下結果:
10
10
10
10
10
10
10
10
10
10
複製程式碼
很多 JavaScript 程式設計師對這種行為已經很熟悉了,但如果你很不解,你並不是一個人。 大多數人期望輸出結果是這樣:
0
1
2
3
4
5
6
7
8
9
複製程式碼
還記得我們上面提到的捕獲變數嗎?
我們傳給
setTimeout
的每一個函式表示式實際上都引用了相同作用域裡的同一個i
。
讓我們花點時間思考一下這是為什麼。 setTimeout
在若干毫秒後執行一個函式,並且是在 for
迴圈結束後。for
迴圈結束後,i
的值為 10
。 所以當函式被呼叫的時候,它會列印出 10
!
一個通常的解決方法是使用立即執行的函式表示式(IIFE)來捕獲每次迭代時i
的值:
for (var i = 0; i < 10; i++) {
(function(i) {
setTimeout(function() { console.log(i); }, 100 * i);
})(i);
}
複製程式碼
這種奇怪的形式我們已經司空見慣了。 引數 i
會覆蓋 for
迴圈裡的 i
,但是因為我們起了同樣的名字,所以我們不用怎麼改 for
迴圈體裡的程式碼。
二、let
宣告
現在你已經知道了 var
存在一些問題,這恰好說明了為什麼用 let
語句來宣告變數。 除了名字不同外, let
與 var
的寫法一致。
let hello = "Hello,An!";
複製程式碼
主要的區別不在語法上,而是語義,我們接下來會深入研究。
塊作用域
當用 let
宣告一個變數,它使用的是詞法作用域或塊作用域。 不同於使用 var
宣告的變數那樣可以在包含它們的函式外訪問,塊作用域變數在包含它們的塊或 for
迴圈之外是不能訪問的。
function f(input) {
let a = 100;
if (input) {
// a 被正常引用
let b = a + 1;
return b;
}
return b;
}
複製程式碼
這裡我們定義了2個變數 a
和 b
。 a
的作用域是 f
函式體內,而 b
的作用域是 if
語句塊裡。
在 catch
語句裡宣告的變數也具有同樣的作用域規則。
try {
throw "oh no!";
}
catch (e) {
console.log("Oh well.");
}
// Error: 'e' doesn't exist here
console.log(e);
複製程式碼
擁有塊級作用域的變數的另一個特點是,它們不能在被宣告之前讀或寫。 雖然這些變數始終“存在”於它們的作用域裡,但在直到宣告它的程式碼之前的區域都屬於 暫時性死區。 它只是用來說明我們不能在 let
語句之前訪問它們:
a++;
// Uncaught ReferenceError: Cannot access 'a' before initialization
let a;
複製程式碼
注意一點,我們仍然可以在一個擁有塊作用域變數被宣告前獲取它。 只是我們不能在變數宣告前去呼叫那個函式。
function foo() {
return a;
}
// 不能在'a'被宣告前呼叫'foo'
// 執行時應該丟擲錯誤
foo();
// Uncaught ReferenceError: Cannot access 'a' before initialization
let a;
複製程式碼
關於暫時性死區的更多資訊,檢視這裡Mozilla Developer Network.
重定義及遮蔽
我們提過使用 var
宣告時,它不在乎你宣告多少次;你只會得到1個。
function f(x) {
var x;
var x;
if (true) {
var x;
}
}
複製程式碼
在上面的例子裡,所有 x
的宣告實際上都引用一個相同的 x
,並且這是完全有效的程式碼。 這經常會成為 bug 的來源。 好的是, let
宣告就不會這麼寬鬆了。
let x = 10;
let x = 20;
// Uncaught SyntaxError: Identifier 'x' has already been declared
複製程式碼
並不是要求兩個均是塊級作用域的宣告才會給出一個錯誤的警告。
function f(x) {
let x = 100;
// Uncaught SyntaxError: Identifier 'x' has already been declared
}
function g() {
let x = 100;
var x = 100;
// Uncaught SyntaxError: Identifier 'x' has already been declared
}
複製程式碼
並不是說塊級作用域變數不能用函式作用域變數來宣告。 而是塊級作用域變數需要在明顯不同的塊裡宣告。
function f(condition, x) {
if (condition) {
let x = 100;
return x;
}
return x;
}
f(false, 0); // 0
f(true, 0); // 100
複製程式碼
在一個巢狀作用域裡引入一個新名字的行為稱做 遮蔽 。 它是一把雙刃劍,它可能會不小心地引入新問題,同時也可能會解決一些錯誤。 例如,假設我們現在用 let
重寫之前的 sumArr
函式。
function sumArr(arrList) {
let sum = 0;
for (let i = 0; i < arrList.length; i++) {
var arr = arrList[i];
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
return sum;
}
複製程式碼
此時將得到正確的結果,因為內層迴圈的 i
可以遮蔽掉外層迴圈的 i
。
通常來講應該避免使用遮蔽,因為我們需要寫出清晰的程式碼。 同時也有些場景適合利用它,你需要好好打算一下。
塊級作用域變數的獲取
在我們最初談及獲取用 var
宣告的變數時,我們簡略地探究了一下在獲取到了變數之後它的行為是怎樣的。 直觀地講,每次進入一個作用域時,它建立了一個變數的環境。 就算作用域內程式碼已經執行完畢,這個環境與其捕獲的變數依然存在。
function theCityThatAlwaysSleeps() {
let getCity;
if (true) {
let city = "Seattle";
getCity = function() {
return city;
}
}
return getCity();
}
複製程式碼
因為我們已經在 city
的環境裡獲取到了 city
,所以就算 if
語句執行結束後我們仍然可以訪問它。
回想一下前面 setTimeout
的例子,我們最後需要使用立即執行的函式表示式來獲取每次 for
迴圈迭代裡的狀態。 實際上,我們做的是為獲取到的變數建立了一個新的變數環境。
當 let
宣告出現在迴圈體裡時擁有完全不同的行為。 不僅是在迴圈裡引入了一個新的變數環境,而是針對每次迭代都會建立這樣一個新作用域。 這就是我們在使用立即執行的函式表示式時做的事,所以在 setTimeout
例子裡我們僅使用 let
宣告就可以了。
for (let i = 0; i < 10 ; i++) {
setTimeout(function() {console.log(i); }, 100 * i);
}
複製程式碼
會輸出與預料一致的結果:
0
1
2
3
4
5
6
7
8
9
複製程式碼
三、const
宣告
const
宣告是宣告變數的另一種方式。
const numLivesForCat = 9;
複製程式碼
它們與 let
宣告相似,但是就像它的名字所表達的,它們被賦值後不能再改變。 換句話說,它們擁有與 let
相同的作用域規則,但是不能對它們重新賦值。
這很好理解,它們引用的值是不可變的。
const numLivesForCat = 9;
const kitty = {
name: "Aurora",
numLives: numLivesForCat,
}
// Error
kitty = {
name: "Danielle",
numLives: numLivesForCat
};
// all "okay"
kitty.name = "Rory";
kitty.name = "Kitty";
kitty.name = "Cat";
kitty.numLives--;
複製程式碼
除非你使用特殊的方法去避免,實際上 const
變數的內部狀態是可修改的。
四、let
vs. const
現在我們有兩種作用域相似的宣告方式,我們自然會問到底應該使用哪個。 與大多數泛泛的問題一樣,答案是:依情況而定。
使用最小特權原則,所有變數除了你計劃去修改的都應該使用const
。 基本原則就是如果一個變數不需要對它寫入,那麼其它使用這些程式碼的人也不能夠寫入它們,並且要思考為什麼會需要對這些變數重新賦值。 使用 const
也可以讓我們更容易的推測資料的流動。
跟據你的自己判斷,如果合適的話,與團隊成員商議一下。
五、解構
解構陣列
最簡單的解構莫過於陣列的解構賦值了:
let input = [1, 2];
let [first, second] = input;
console.log(first); // 1
console.log(second); // 2
複製程式碼
這建立了2個命名變數 first
和 second
。 相當於使用了索引,但更為方便:
first = input[0];
second = input[1];
複製程式碼
解構作用於已宣告的變數會更好:
[first, second] = [second, first];
複製程式碼
作用於函式引數:
function f([first, second]) {
console.log(first);
console.log(second);
}
f(input);
複製程式碼
你可以在陣列裡使用 ...
語法建立剩餘變數:
let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 1
console.log(rest); // [ 2, 3, 4 ]
複製程式碼
當然,由於是JavaScript, 你可以忽略你不關心的尾隨元素:
let [first] = [1, 2, 3, 4];
console.log(first); // 1
複製程式碼
或其它元素:
let [, second, , fourth] = [1, 2, 3, 4];
複製程式碼
物件解構
你也可以解構物件:
let o = {
a: "foo",
b: 12,
c: "bar"
};
let { a, b } = o;
複製程式碼
這通過 o.a
and o.b
建立了 a
和 b
。 注意,如果你不需要 c
你可以忽略它。
就像陣列解構,你可以用沒有宣告的賦值:
({ a, b } = { a: "baz", b: 101 });
複製程式碼
注意,我們需要用括號將它括起來,因為 Javascript 通常會將以 {
起始的語句解析為一個塊。
你可以在物件裡使用 ...
語法建立剩餘變數:
let { a, ...passthrough } = o;
let total = passthrough.b + passthrough.c.length;
複製程式碼
六、展開
展開操作符正與解構相反。 它允許你將一個陣列展開為另一個陣列,或將一個物件展開為另一個物件。 例如:
let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];
複製程式碼
這會令 bothPlus
的值為 [0, 1, 2, 3, 4, 5]
。 展開操作建立了 first
和 second
的一份淺拷貝。 它們不會被展開操作所改變。
你還可以展開物件:
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };
複製程式碼
search
的值為 { food: "rich", price: "$$", ambiance: "noisy" }
。 物件的展開比陣列的展開要複雜的多。 像陣列展開一樣,它是從左至右進行處理,但結果仍為物件。 這就意味著出現在展開物件後面的屬性會覆蓋前面的屬性。 因此,如果我們修改上面的例子,在結尾處進行展開的話:
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { food: "rich", ...defaults };
複製程式碼
那麼,defaults
裡的 food
屬性會重寫 food: "rich"
,在這裡這並不是我們想要的結果。
物件展開還有其它一些意想不到的限制。 首先,它僅包含物件 自身的可列舉屬性。 大體上是說當你展開一個物件例項時,你會丟失其方法:
class C {
p = 12;
m() {
}
}
let c = new C();
let clone = { ...c };
clone.p; // ok
clone.m(); // error!
複製程式碼
七、new、this、class、函式
this 與 new
new 關鍵字建立的物件實際上是對新物件 this 的不斷賦值,並將 __proto__
指向類的 prototype 所指向的物件。
var SuperType = function (name) {
var nose = 'nose' // 私有屬性
function say () {} // 私有方法
// 特權方法
this.getName = function () {}
this.setName = function () {}
this.mouse = 'mouse' // 物件公有屬性
this.listen = function () {} // 物件公有方法
// 構造器
this.setName(name)
}
SuperType.age = 10 // 類靜態公有屬性(物件不能訪問)
SuperType.read = function () {} // 類靜態公有方法(物件無法訪問)
SuperType.prototype = { // 物件賦值(也可以一一賦值)
isMan: 'true', // 公有屬性
write: function () {} // 公有方法
}
var instance = new SuperType()
複製程式碼
在函式呼叫前增加 new
,相當於把 SuperType
當成一個建構函式(雖然它僅僅只是個函式),然後建立一個 {} 物件並把 SuperType
中的 this
指向那個物件,以便可以通過類似 this.mouse
的形式去設定一些東西,然後把這個物件返回。
具體來講,只要在函式呼叫前加上 new
操作符,你就可以把任何函式當做一個類的建構函式來用。
加 new
在上例中,我們可以看到:在建構函式內定義的 私有變數或方法 ,以及類定義的 靜態公有屬性及方法 ,在 new 的例項物件中都將 無法訪問 。
不加 new
如果你呼叫 SuperType()
時沒有加 new
,其中的 this
會指向某個全域性且無用的東西(比如,window
或者 undefined
),因此我們的程式碼會崩潰,或者做一些像設定 window.mouse
之類的傻事。
let instance1 = SuperType();
console.log(instance1.mouse);
// Uncaught TypeError: Cannot read property 'mouse' of undefined
console.log(window.mouse);
// mouse
複製程式碼
函式、類
函式
function Bottle(name) {
this.name = name;
}
// + new
let bottle = new Bottle('bottle'); // ✅ 有效: Bottle {name: "bottle"}
console.log(bottle.name) // bottle
// 不加 new
let bottle1 = Bottle('bottle'); // ? 這種呼叫方法讓人很難理解
console.log(bottle1.name); // Uncaught TypeError: Cannot read property 'name' of undefined
console.log(window.name); // bottle
複製程式碼
類
class Bottle {
constructor(name) {
this.name = name;
}
sayHello() {
console.log('Hello, ' + this.name);
}
}
// + new
let bottle = new Bottle('bottle');
bottle.sayHello(); // ✅ 依然有效,列印:Hello, bottle
// 不加 new
let bottle1 = Bottle('bottle'); // ? 立即失敗
// Uncaught TypeError: Class constructor Bottle cannot be invoked without 'new'
複製程式碼
對比使用
let fun = new Fun();
// ✅ 如果 Fun 是個函式:有效
// ✅ 如果 Fun 是個類:依然有效
let fun1 = Fun(); // 我們忘記使用 `new`
// ? 如果 Fun 是個長得像建構函式的方法:令人困惑的行為
// ? 如果 Fun 是個類:立即失敗
複製程式碼
即
new Fun() | Fun | |
---|---|---|
class | ✅ this 是一個 Fun 例項 |
? TypeError |
function | ✅ this 是一個 Fun 例項 |
? this 是 window 或 undefined |
使用 new 的怪異之處
return 無效
function Bottle() {
return 'Hello, AnGe';
}
Bottle(); // ✅ 'Hello, AnGe'
new Bottle(); // ? Bottle {}
複製程式碼
箭頭函式
對於箭頭函式,使用 new
會報錯?
const Bottle = () => {console.log('Hello, AnGe')};
new Bottle(); // Uncaught TypeError: Bottle is not a constructor
複製程式碼
這個行為是遵循箭頭函式的設計而刻意為之的。箭頭函式的一個附帶作用是它沒有自己的 this
值 —— this
解析自離得最近的常規函式:
function AnGe() {
this.name = 'AnGe'
return () => {console.log('Hello, ' + this.name)};
}
let anGe = new AnGe();
console.log(anGe()); // Hello, AnGe
複製程式碼
所以**箭頭函式沒有自己的 this。**但這意味著它作為建構函式是完全無用的!
總結:箭頭函式
- this 指向定義時的環境。
- 不可 new 例項化。
- this 不可變。
- 沒有 arguments 物件。
允許一個使用 new
呼叫的函式返回另一個物件以 覆蓋 new
的返回值
先看一個例子:
function Vector(x, y) {
this.x = x;
this.y = y;
}
var v1 = new Vector(0, 0);
var v2 = new Vector(0, 0);
console.log(v1 === v2); // false
v1.x = 1;
console.log(v2); // Vector {x: 0, y: 0}
複製程式碼
對於這個例子,一目瞭然,沒什麼可說的。
那麼再看下面一個例子,思考一下為什麼 b === c
為 true
喃?:
let zeroVector = null;
// 建立了一個懶變數 zeroVector = null;
function Vector(x, y) {
if (zeroVector !== null) {
// 複用同一個例項
return zeroVector;
}
zeroVector = this;
this.x = x;
this.y = y;
}
var v1 = new Vector(0, 0);
var v2 = new Vector(0, 0);
console.log(v1 === v2); // true
v1.x = 1;
console.log(v2); // Vector {x: 1, y: 0}
複製程式碼
這是因為,JavaScript 允許一個使用 new
呼叫的函式返回另一個物件以 覆蓋 new
的返回值。這在我們利用諸如「物件池模式」來對元件進行復用時可能是有用的。
參考:
TypeScript Variable Declarations
系列文章
- JS 系列一:var、let、const、解構、展開、new、this、class、函式
- JS 系列二:深入 constructor、prototype、proto、[[Prototype]] 及 原型鏈
- JS 系列三:繼承的 六 種實現方式
- JS 系列四:深入剖析 instanceof 運算子
想看更過系列文章,點選前往 github 部落格主頁
走在最後,歡迎關注:前端瓶子君,每日更新
、