今天我們進入到語法部分的學習。在講解具體的語法結構之前,這一堂課我首先要給你介紹一下 JavaScript 語法的一些基本規則。
腳本和模塊
首先,JavaScript 有兩種源文件,一種叫做腳本,一種叫做模塊。這個區分是在 ES6 引入了模塊機制開始的,在 ES5 和之前的版本中,就只有一種源文件類型(就只有腳本)。
腳本是可以由瀏覽器或者 node 環境引入執行的,而模塊只能由 JavaScript 代碼用 import 引入執行。
從概念上,我們可以認爲腳本具有主動性的 JavaScript 代碼段,是控制宿主完成一定任務的代碼;而模塊是被動性的 JavaScript 代碼段,是等待被調用的庫。
我們對標準中的語法產生式做一些對比,不難發現,實際上模塊和腳本之間的區別僅僅在於是否包含 import 和 export。
腳本是一種兼容之前的版本的定義,在這個模式下,沒有 import 就不需要處理加載“.js”文件問題。
現代瀏覽器可以支持用 script 標籤引入模塊或者腳本,如果要引入模塊,必須給 script 標籤添加 type=“module”。如果引入腳本,則不需要 type。
<script type="module" src="xxxxx.js"></script>
這樣,就回答了我們標題中的問題,script 標籤如果不加type=“module”
,默認認爲我們加載的文件是腳本而非模塊,如果我們在腳本中寫了 export,當然會拋錯。
腳本中可以包含語句。模塊中可以包含三種內容:import 聲明,export 聲明和語句。普通語句我們會在下一課專門給你講解,下面我們就來講講 import 聲明和 export 聲明。
import 聲明
我們首先來介紹一下 import 聲明,import 聲明有兩種用法,一個是直接 import 一個模塊,另一個是帶 from 的 import,它能引入模塊裏的一些信息。
import "mod"; //引入一個模塊import v from "mod"; //把模塊默認的導出值放入變量v
直接 import 一個模塊,只是保證了這個模塊代碼被執行,引用它的模塊是無法獲得它的任何信息的。
帶 from 的 import 意思是引入模塊中的一部分信息,可以把它們變成本地的變量。
帶 from 的 import 細分又有三種用法,我們可以分別看下例子:
-
import x from "./a.js"
引入模塊中導出的默認值。 -
import {a as x, modify} from "./a.js";
引入模塊中的變量。 -
import * as x from "./a.js"
把模塊中所有的變量以類似對象屬性的方式引入。
第一種方式還可以跟後兩種組合使用。
-
import d, {a as x, modify} from "./a.js"
-
import d, * as x from "./a.js"
語法要求不帶 as 的默認值永遠在最前。注意,這裏的變量實際上仍然可以受到原來模塊的控制。
我們看一個例子,假設有兩個模塊 a 和 b。我們在模塊 a 中聲明瞭變量和一個修改變量的函數,並且把它們導出。我們用 b 模塊導入了變量和修改變量的函數。
模塊 a:
export var a = 1;export function modify(){
a = 2;
}
模塊 b:
import {a, modify} from "./a.js";console.log(a);modify();console.log(a);
當我們調用修改變量的函數後,b 模塊變量也跟着發生了改變。這說明導入與一般的賦值不同,導入後的變量只是改變了名字,它仍然與原來的變量是同一個。
export 聲明
我們再來說說 export 聲明。與 import 相對,export 聲明承擔的是導出的任務。
模塊中導出變量的方式有兩種,一種是獨立使用 export 聲明,另一種是直接在聲明型語句前添加 export 關鍵字。
獨立使用 export 聲明就是一個 export 關鍵字加上變量名列表,例如:
export {a, b, c};
我們也可以直接在聲明型語句前添加 export 關鍵字,這裏的 export 可以加在任何聲明性質的語句之前,整理如下:
- var
- function (含 async 和 generator)
- class
- let
- const
export 還有一種特殊的用法,就是跟 default 聯合使用。export default 表示導出一個默認變量值,它可以用於 function 和 class。這裏導出的變量是沒有名稱的,可以使用import x from "./a.js"
這樣的語法,在模塊中引入。
export default
還支持一種語法,後面跟一個表達式,例如:
var a = {};export default a;
但是,這裏的行爲跟導出變量是不一致的,這裏導出的是值,導出的就是普通變量 a 的值,以後 a 的變化與導出的值就無關了,修改變量 a,不會使得其他模塊中引入的 default 值發生改變。
在 import 語句前無法加入 export,但是我們可以直接使用 export from 語法。
export a from "a.js"
JavaScript 引擎除了執行腳本和模塊之外,還可以執行函數。而函數體跟腳本和模塊有一定的相似之處,所以接下來,給你講講函數體的相關知識。
函數體
執行函數的行爲通常是在 JavaScript 代碼執行時,註冊宿主環境的某些事件觸發的,而執行的過程,就是執行函數體(函數的花括號中間的部分)。
我們先看一個例子,感性地理解一下:
setTimeout(function(){ console.log("go go go");
}, 10000)
這段代碼通過 setTimeout 函數註冊了一個函數給宿主,當一定時間之後,宿主就會執行這個函數。
你還記得嗎,我們前面已經在運行時這部分講過,宿主會爲這樣的函數創建宏任務。
當我們學習了語法之後,我們可以認爲,宏任務中可能會執行的代碼包括“腳本 (script)”“模塊(module)”和“函數體(function body)”。正因爲這樣的相似性,我們把函數體也放到本課來講解。
函數體其實也是一個語句的列表。跟腳本和模塊比起來,函數體中的語句列表中多了 return 語句可以用。
函數體實際上有四種,下面,我來分別介紹一下。
普通函數體,例如:
function foo(){ //Function body}
異步函數體,例如:
async function foo(){ //Function body}
生成器函數體,例如:
function *foo(){ //Function body}
異步生成器函數體,例如:
async function *foo(){ //Function body}
上面四種函數體的區別在於:能否使用 await 或者 yield 語句。
關於函數體、模塊和腳本能使用的語句,我整理了一個表格,你可以參考一下:
講完了三種語法結構,我再來介紹兩個 JavaScript 語法的全局機制:預處理和指令序言。
這兩個機制對於我們解釋一些 JavaScript 的語法現象非常重要。不理解預處理機制我們就無法理解 var 等聲明類語句的行爲,而不理解指令序言,我們就無法解釋嚴格模式。
預處理
JavaScript 執行前,會對腳本、模塊和函數體中的語句進行預處理。預處理過程將會提前處理 var、函數聲明、class、const 和 let 這些語句,以確定其中變量的意義。
因爲一些歷史包袱,這一部分內容非常複雜,首先我們看一下 var 聲明。
var 聲明
var 聲明永遠作用於腳本、模塊和函數體這個級別,在預處理階段,不關心賦值的部分,只管在當前作用域聲明這個變量。
我們還是從實例來進行學習。
var a = 1;function foo() { console.log(a); var a = 2;
}foo();
這段代碼聲明瞭一個腳本級別的 a,又聲明瞭 foo 函數體級別的 a,我們注意到,函數體級的var出現在 console.log
語句之後。
但是預處理過程在執行之前,所以有函數體級的變量 a,就不會去訪問外層作用域中的變量 a 了,而函數體級的變量 a 此時還沒有賦值,所以是 undefined。我們再看一個情況:
var a = 1;function foo() { console.log(a); if(false) { var a = 2;
}
}foo();
這段代碼比上一段代碼在 var a = 2
之外多了一段 if,我們知道 if(false) 中的代碼永遠不會被執行,但是預處理階段並不管這個,var 的作用能夠穿透一切語句結構,它只認腳本、模塊和函數體三種語法結構。所以這裏結果跟前一段代碼完全一樣,我們會得到 undefined。
我們看下一個例子,我們在運行時部分講過類似的例子。
var a = 1;function foo() { var o= {a:3} with(o) { var a = 2;
} console.log(o.a); console.log(a);
}foo();
在這個例子中,我們引入了 with 語句,我們用 with(o) 創建了一個作用域,並把 o 對象加入詞法環境,在其中使用了 var a = 2;
語句。
在預處理階段,只認var中聲明的變量,所以同樣爲 foo 的作用域創建了 a 這個變量,但是沒有賦值。
在執行階段,當執行到 var a = 2
時,作用域變成了 with 語句內,這時候的 a 被認爲訪問到了對象 o 的屬性 a,所以最終執行的結果,我們得到了 2 和 undefined。
這個行爲是 JavaScript 公認的設計失誤之一,一個語句中的 a 在預處理階段和執行階段被當做兩個不同的變量,嚴重違背了直覺,但是今天,在 JavaScript 設計原則“don’t break the web”之下,已經無法修正了,所以你需要特別注意。
因爲早年 JavaScript 沒有 let 和 const,只能用 var,又因爲 var 除了腳本和函數體都會穿透,人民羣衆發明了“立即執行的函數表達式(IIFE)”這一用法,用來產生作用域,例如:
for(var i = 0; i < 20; i ++) { void function(i){ var div = document.createElement("div");
div.innerHTML = i;
div.onclick = function(){ console.log(i);
} document.body.appendChild(div);
}(i);
}
這段代碼非常經典,常常在實際開發中見到,也經常被用作面試題,爲文檔添加了 20 個 div 元素,並且綁定了點擊事件,打印它們的序號。
我們通過 IIFE 在循環內構造了作用域,每次循環都產生一個新的環境記錄,這樣,每個 div 都能訪問到環境中的 i。
如果我們不用 IIFE:
for(var i = 0; i < 20; i ++) { var div = document.createElement("div");
div.innerHTML = i;
div.onclick = function(){ console.log(i);
} document.body.appendChild(div);
}
這段代碼的結果將會是點每個 div 都打印 20,因爲全局只有一個 i,執行完循環後,i 變成了 20。
function 聲明
function 聲明的行爲原本跟 var 非常相似,但是在最新的 JavaScript 標準中,對它進行了一定的修改,這讓情況變得更加複雜了。
在全局(腳本、模塊和函數體),function 聲明表現跟 var 相似,不同之處在於,function 聲明不但在作用域中加入變量,還會給它賦值。
我們看一下 function 聲明的例子:
console.log(foo);function foo(){
}
這裏聲明瞭函數 foo,在聲明之前,我們用 console.log 打印函數 foo,我們可以發現,已經是函數 foo 的值了。
function 聲明出現在 if 等語句中的情況有點複雜,它仍然作用於腳本、模塊和函數體級別,在預處理階段,仍然會產生變量,它不再被提前賦值:
console.log(foo);if(true) { function foo(){
}
}
這段代碼得到 undefined。如果沒有函數聲明,則會拋出錯誤。
這說明 function 在預處理階段仍然發生了作用,在作用域中產生了變量,沒有產生賦值,賦值行爲發生在了執行階段。
出現在 if 等語句中的 function,在 if 創建的作用域中仍然會被提前,產生賦值效果,我們會在下一節課繼續討論。
class 聲明
class 聲明在全局的行爲跟 function 和 var 都不一樣。
在 class 聲明之前使用 class 名,會拋錯:
console.log(c);class c{
}
這段代碼我們試圖在 class 前打印變量 c,我們得到了個錯誤,這個行爲很像是 class 沒有預處理,但是實際上並非如此。
我們看個複雜一點的例子:
var c = 1;function foo(){ console.log(c); class c {}
}foo();
這個例子中,我們把 class 放進了一個函數體中,在外層作用域中有變量 c。然後試圖在 class 之前打印 c。
執行後,我們看到,仍然拋出了錯誤,如果去掉 class 聲明,則會正常打印出 1,也就是說,出現在後面的 class 聲明影響了前面語句的結果。
這說明,class 聲明也是會被預處理的,它會在作用域中創建變量,並且要求訪問它時拋出錯誤。
class 的聲明作用不會穿透 if 等語句結構,所以只有寫在全局環境纔會有聲明作用,這部分我們將會在下一節課講解。
這樣的 class 設計比 function 和 var 更符合直覺,而且在遇到一些比較奇怪的用法時,傾向於拋出錯誤。
按照現代語言設計的評價標準,及早拋錯是好事,它能夠幫助我們儘量在開發階段就發現代碼的可能問題。
指令序言機制
腳本和模塊都支持一種特別的語法,叫做指令序言(Directive Prologs)。
這裏的指令序言最早是爲了 use strict 設計的,它規定了一種給 JavaScript 代碼添加元信息的方式。
"use strict";function f(){ console.log(this);
};
f.call(null);
這段代碼展示了嚴格模式的用法,我這裏定義了函數 f,f 中打印 this 值,然後用 call 的方法調用 f,傳入 null 作爲 this 值,我們可以看到最終結果是 null 原封不動地被當做 this 值打印了出來,這是嚴格模式的特徵。
如果我們去掉嚴格模式的指令需要,打印的結果將會變成 global。
"use strict"
是 JavaScript 標準中規定的唯一一種指令序言,但是設計指令序言的目的是,留給 JavaScript 的引擎和實現者一些統一的表達方式,在靜態掃描時指定 JavaScript 代碼的一些特性。
例如,假設我們要設計一種聲明本文件不需要進行 lint 檢查的指令,我們可以這樣設計:
"no lint";"use strict";function doSth(){ //......}//......
JavaScript 的指令序言是隻有一個字符串直接量的表達式語句,它只能出現在腳本、模塊和函數體的最前面。
我們看兩個例子:
function doSth(){ //......}"use strict";var a = 1;//......
這個例子中,"use strict"沒有出現在最前,所以不是指令序言。
'use strict';function doSth(){ //......}var a = 1;//......
這個例子中,'use strict'是單引號,這不妨礙它仍然是指令序言。
結語
今天,我們一起進入了 JavaScript 的語法部分,在開始學習之前,我先介紹了一部分語法的基本規則。
我們首先介紹了 JavaScript 語法的全局結構,JavaScript 有兩種源文件,一種叫做腳本,一種叫做模塊。介紹完腳本和模塊的基礎概念,我們再來把它們往下分,腳本中可以包含語句。模塊中可以包含三種內容:import 聲明,export 聲明和語句。
最後,我介紹了兩個 JavaScript 語法的全局機制:預處理和指令序言。
最後,給你留一個小任務,我們試着用 babel,分析一段 JavaScript 的模塊代碼,並且找出它中間的所有 export 的變量。