Javascript是一種解釋型的動態語言。
在程序中,有編譯型語言和解釋型語言。那么什么是編譯型語言,什么是解釋型語言呢?
編譯型語言: 它首先將源代碼編譯成機器語言,再由機器運行機器碼(二進制)。
解釋型語言: 相對於編譯型語言而存在的,源代碼不是直接編譯為目標代碼,而是將源代碼翻譯成中間代碼,再由解釋器對中間代碼進行解釋運行的。
比如javascript/python等都是解釋型語言(但是javascript是先編譯完成后,再進行解釋的)。
主要的編譯型語言有c++, 解釋型語言有Javascript, 和半解釋半編譯(比如java)。
一. 了解代碼是如何運行的?
我們都知道,代碼是由CPU執行的,但是CPU不能直接執行我們的if...else這樣的代碼,它只能執行二進制指令,但是二進制對應我們的可讀性來說並不友好,比如二進制 11100000 這樣的,我們並不知道它代表的是什么含義, 因此科學家們就發明了匯編語言。
匯編語言
什么是匯編語言? 它解決了什么問題?
匯編語言是二進制指令的文本形式,和二進制指令是一一對應的關系,比如加法指令 00000011 寫成匯編語言就是ADD。那么匯編語言的作用就是將ADD這樣的還原成二進制,那么二進制就可以直接被CPU執行。它最主要的作用就是解決二進制指令的可讀性問題。
但是匯編語言也有缺點:
1. 編寫的代碼非常難懂,不好維護。
2. 它是一種非常低的語言,它只針對特定的體系結構和處理器進行優化。
3. 開發效率低。容易出現bug,不好調試。
因此這個時候就發明了高級語言。
高級語言
為什么我們叫它是高級語言? 因為它更符合我們人類的思維和閱讀習慣,因為代碼是寫給人看的,不是寫給機器看的,只是我們的計算機能運行而已,比如我們之前寫的 if...else這樣的代碼 比我們之前的 二進制 11100000 可讀性好很多,但是我們的計算機並不能直接執行高級語言。所以我們需要把高級語言轉化為編譯語言/機器指令,我們計算機CPU才能執行。那么這個過程就叫做編譯。
我們的javascript是一種高級語言,因此我們的javascript也需要編譯后才能執行,但是我們前面說過,javascript也是一種解釋型語言, 那么它和編譯型語言有什么區別呢? 因此我們可以先從編譯說起。
了解編譯
上面了解了編譯的概念,那么我們來了解下我們的js代碼為什么需要編譯? 比如同樣一份C++代碼在windows上會編譯成 .obj文件,但是在Linux上則會生成.o文件。他們兩者生成的文件是不能通用的。這是因為可執行文件除了代碼以外還需要操作系統,API,內存,線程,進程等系統資源。但是不同的操作系統他們實現的方式也是不相同的。因此針對不同的操作系統我們需要使用編譯型語言對他們分別進行編譯等。
了解解釋型語言
先看下編譯型語言, 編譯型語言是代碼在 運行前 編譯器將人類可以理解的語言轉換成機器可以理解的語言。
解釋型語言: 也是將人類可以理解的語言轉換成機器可以理解的語言,但是它是在 運行時 轉換的。
最主要的區別是: 編譯型語言編寫的代碼在編譯后直接可以被CPU執行及運行的。但是解釋型語言需要在環境中安裝解釋器才能被解析。
打個比方說: 我現在要演講一篇中文文稿,但是演講現場有個外國人,他只懂英文,因此我們事先把整個文章翻譯成英文給他們聽(這就是編譯型語言),我們也可以同聲傳譯的方法一句一句邊讀邊翻譯給他們聽。(這就是解釋型語言)。
二:了解javascript執行過程
1. 了解javascript解析引擎
javascript的引擎的作用簡單的來講,就是能夠讀懂javascript代碼,並且准確地給出運行結果的程序,比如說,當我們寫 var temp = 1+1; 這樣一段代碼的時候,javascript引擎就能解析我們這段代碼,並且將temp的值變為2。
Javascript引擎的基本原理是: 它可以把JS的源代碼轉換成高效,優化的代碼,這樣就可以通過瀏覽器解析甚至可以被嵌入到應用當中。
每個javascript引擎都實現了一個版本的ECMAScript, javascript只是它的一個分支,那么ECMAScript在不斷的發展,那么javascript的引擎也會在不斷的改變。
為什么會有那么多引擎,那是因為他們每個都被設計到不同的web瀏覽器或者像Node.js那樣的運行環境當中。他們唯一的目的是讀取和編譯javascript代碼。
那么常見的javascript引擎有如下:
Mozilla瀏覽器 -----> 解析引擎為 Spidermonkey(由c語言實現的) Chrome瀏覽器 ------> 解析引擎為 V8(它是由c++實現的) Safari瀏覽器 ------> 解析引擎為 JavaScriptCore(c/c++) IE and Edge ------> 解析引擎為 Chakra(c++) Node.js ------> 解析引擎為 V8
解析引擎是根據 ECMAScript定義的語言標准來動態執行javascript字符串的。
那么解析引擎是如何解析JS的呢?
解析JS分為2個階段:如下所示:
如上圖我們可知: javascript解析分為:語法解析階段 和 運行階段,其中語法解析階段又分為2種,分別為: 詞法分析和語法分析。
運行階段分為:預解析 和 運行階段。
注意:在javascript解析過程中,如果遇到錯誤,會直接跳出當前的代碼塊,直接執行下一個script代碼段,因此在同一個script內的代碼段有錯誤的話就不會執行下去。但是它不會影響下一個script內的代碼段。
1. 語法解析階段
語法解析階段 包括 詞法分析 和 語法分析。
1.1. 詞法分析
詞法分析會將js代碼中的字符串分割為有意義的代碼塊,這些代碼塊我們可以稱之為 "詞法單元"。比如簡單的如下代碼:
var a = 1; 那么這行代碼會被分為以下詞法單元: var、a、=、1 那么這些零散的詞法單元會組成一個詞法單元流進行解析。
比如上面詞義分析后結果變成如下:
[ { "type": "Keyword", "value": "var" }, { "type": "Identifier", "value": "a" }, { "type": "Punctuator", "value": "=" }, { "type": "Numeric", "value": "1" } ]
上面的轉換結果,我們可以使用這個在線的網址轉換(https://esprima.org/demo/parse.html)
我們可以把babel編譯器的代碼拿過來使用下,看下如何使用javascript來封裝詞法分析,僅供參考代碼如下:
<!DOCTYPE html> <html> <head> <title></title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0"> </head> <body> <div id="app"> </div> <script type="text/javascript"> function tokenizer(input) { // 記錄當前解析到詞的位置 var current = 0; // tokens 用來保存我們解析的token var tokens = []; // 利用循環進行解析 while(current < input.length) { // 提取出當前要解析的字符 var char = input[current]; // 處理符號: 檢查是否是符號 var PUNCTUATOR = /[`~!@#$%^&*()_\-+=<>?:"{}|,.\/;'\\[\]·~!@#¥%……&*()——\-+={}|《》?:“”【】、;‘’,。、]/im; if (PUNCTUATOR.test(char)) { // 創建變量用於保存匹配的符號 var punctuators = char; // 判斷是否是箭頭函數的符號 if (char === '=' && input[current + 1] === '>') { punctuators += input[++current]; } current++; // 最后把數據存入到tokens中 tokens.push({ type: 'Punctuator', value: punctuators }); // 進入下一次循環 continue; } // 下面是處理空格,如果是空格的話,則直接進入下一個循環 var WHITESPACE = /\s/; if (WHITESPACE.test(char)) { current++; continue; } // 處理數字,檢查是否是數字 var NUMBERS = /[0-9]/; if (NUMBERS.test(char)) { // 創建變量,用於保存匹配的數字 var number = ''; // 循環當前的字符及下一個字符,直到不是數字為止 while(NUMBERS.test(char)) { number += char; char = input[++current]; } // 最后我們把數據更新到tokens中 tokens.push({ type: 'Numeric', value: number }); // 進入下一個循環 continue; } // 檢查是否是字符 var LETTERS = /[a-z]/i; if (LETTERS.test(char)) { // 創建一個臨時變量保存該字符 var value = ''; // 循環遍歷所有的字母 while(LETTERS.test(char)) { value += char; char = input[++current]; } // 判斷當前的字符串是否是關鍵字 var KEYWORD = /function|var|return|let|const|if|for/; if (KEYWORD.test(value)) { // 標記關鍵字 tokens.push({ type: 'Keyword', value: value }) } else { // 標記變量 tokens.push({ type: 'Identifier', value: value }) } // 進入下一次循環 continue; } // 如果我們沒有匹配上任何類型的token; 那么就拋出一個錯誤 throw new TypeError('I dont konw what this character is:' + char); } // 最后我們返回詞法單元數組 return tokens; } var str = 'var a = 1'; console.log(tokenizer(str)); </script> </body> </html>
效果執行打印如下所示:
可以看到,和上面的打印效果是一樣的。
代碼我們可以簡單的分析下如下:
首先代碼調用如下:
var str = 'var a = 1'; console.log(tokenizer(str));
如上可以看到,該str的長度為9,current從0開始,也就是說從第一個字符開始進行解析,判斷該字符是否為 符號、空格、數字、字母等操作。如果是字母的話,繼續判斷下一個字符是否是字母,依次類推,直到下一個字符不是字母的話,就獲取該值,因此獲取到的 value為 'var';
然后會判斷該字符串是否為關鍵字,如關鍵字: var KEYWORD = /function|var|return|let|const|if|for/;這些其中的一個,如果是的話,直接標記為關鍵字,存入tokens數組中,如下代碼:
tokens.push({ type: 'Keyword', value: value }); 因此 tokens = [{ type: 'Keyword', value: 'var' }];
然后繼續循環,此時 current = 3了; 因此是空格,如果是空格的話,代碼會跳過該循環,進行執行下一次循環, 因此current=4了; 因此vaule = a 了;因此就執行標記變量的代碼,如下所示:
// 標記變量 tokens.push({ type: 'Identifier', value: value });
因此tokens的值為 = [{ type: 'Keyword', value: 'var' }, { type: 'Identifier', value: 'a' }];
繼續下一次循環 current = 5; 可知,也是一個空格,跳過該空格,繼續下一次循環,因此current = 6; 此時的value = '='; 因此會進入 檢查是否是符號 是代碼內部,因此 tokens 值變為如下:
tokens = [ { type: 'Keyword', value: 'var' }, { type: 'Identifier', value: 'a' }, { type: 'Punctuator', value: '=' } ];
同理 current = 7 也是一個空格,因此跳過循環, 此時current = 8; 字符就變為 1;即 value = 1; 因此會進入 檢查是否是數字if語句內部,該內部也會依次循環下一個字符是否為數字,直到不是數字為止。因此 value = 1; 最后的tokens的值變為:
tokens = [ { type: 'Keyword', value: 'var' }, { type: 'Identifier', value: 'a' }, { type: 'Punctuator', value: '=' }, { type: 'Numeric', value: '1' } ];
如上就是一個詞法分析的一個整個過程。
1.2. 語法分析
語法分析在這個過程中會將詞法單元流轉換成一顆 抽象語法樹(AST)。比如 var a = 1; 的詞法單元流就會被解析成下面的AST;
我們也可以使用這個在線的網址轉換(https://esprima.org/demo/parse.html),結果變為如下所示:
{ "type": "Program", "body": [ { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "a" }, "init": { "type": "Literal", "value": 1, "raw": "1" } } ], "kind": "var" } ], "sourceType": "script" }
基本的解析成AST函數代碼如下:
// 接收tokens作為參數, 生成抽象語法樹AST function parser(tokens) { // 記錄當前解析到詞的位置 var current = 0; // 通過遍歷來解析token節點 function walk() { // 從token中第一項進行解析 var token = tokens[current]; // 檢查是不是數字類型 if (token.type === 'Numeric') { // 如果是數字類型的話,把current當前的指針移動到下一個位置 current++; // 然后返回一個新的AST節點 return { type: 'Literal', value: Number(token.value), row: token.value } } // 檢查是不是變量類型 if (token.type === 'Identifier') { // 如果是,把current當前的指針移動到下一個位置 current++; // 然后返回我們一個新的AST節點 return { type: 'Identifier', name: token.value } } // 檢查是不是運輸符類型 if (token.type === 'Punctuator') { // 如果是,current自增 current++; // 判斷運算符類型,根據類型返回新的AST節點 if (/[\+\-\*/]/im.test(token.value)) { return { type: 'BinaryExpression', operator: token.value } } if (/\=/.test(token.value)) { return { type: 'AssignmentExpression', operator: token.value } } } // 檢查是不是關鍵字 if (token.type === 'Keyword') { var value = token.value; // 檢查是不是定義的語句 if (value === 'var' || value === 'let' || value === 'const') { current++; // 獲取定義的變量 var variable = walk(); // 判斷是否是賦值符號 var equal = walk(); var rightVar; if (equal.operator === '=') { // 獲取所賦予的值 rightVar = walk(); } else { // 不是賦值符號, 說明只是定義的變量 rightVar = null; current--; } // 定義聲明 var declaration = { type: 'VariableDeclarator', id: variable, // 定義的變量 init: rightVar }; // 定義要返回的節點 return { type: 'VariableDeclaration', declarations: [declaration], kind: value } } } // 遇到一個未知類型就拋出一個錯誤 throw new TypeError(token.type); } // 現在,我們創建一個AST,根節點是一個類型為 'Program' 的節點 var ast = { type: 'Program', body: [], sourceType: 'script' }; // 循環執行walk函數,把節點放入到ast.body中 while(current < tokens.length) { ast.body.push(walk()); } // 最后返回我們的AST return ast; } var tokens = [ { type: 'Keyword', value: 'var' }, { type: 'Identifier', value: 'a' }, { type: 'Punctuator', value: '=' }, { type: 'Numeric', value: '1' } ]; console.log(parser(tokens));
結果如下所示:
我們可以對比下,打印的結果和上面的結果是一樣的, 代碼是從babel拿過來的,簡單的理解下就好了。
轉換
我們對生成的AST樹節點需要進行處理下,比如我們使用ES6編寫的代碼,比如用到了let,const這樣的,我們需要轉換成var。
因此我們需要對AST樹節點進行轉換操作。
轉換AST的時候,我們可以添加、移動、替換及刪除AST抽象樹中的節點操作。
基本的代碼如下:
/* 為了修改AST抽象樹,我們首先要對節點進行遍歷 @param AST語法樹 @param visitor定義轉換函數,也可以使用visitor函數進行轉換 */ function traverser(ast, visitor) { // 遍歷樹中的每個節點 function traverseArray(array, parent) { if (typeof array.forEach === 'function') { array.forEach(function(child) { traverseNode(child, parent); }); } } function traverseNode(node, parent) { // 看下 vistory中有沒有對應的type處理函數 var method = visitor[node.type]; if (method) { method(node, parent); } switch(node.type) { // 從頂層的Program開始 case 'Program': traverseArray(node.body, node); break; // 如下的是不需要轉換的 case 'VariableDeclaration': case 'VariableDeclarator': case 'AssignmentExpression': case 'Identifier': case 'Literal': break; default: throw new TypeError(node.type) } } traverseNode(ast, null) } /* 下面是轉換器,它用於遍歷過程中轉換數據, 我們接收之前的AST樹作為參數,最后會生成一個新的AST抽象樹 */ function transformer(ast) { // 創建新的ast抽象樹 var newAst = { type: 'Program', body: [], sourceType: 'script' }; ast._context = newAst.body; // 我們把AST 和 vistor 作為參數傳入進去 traverser(ast, { VariableDeclaration: function(node, parent) { var variableDeclaration = { type: 'VariableDeclaration', declarations: node.declarations, kind: 'var' }; // 把新的 VariableDeclaration 放入到context中 parent._context.push(variableDeclaration); } }); // 最后返回創建號的新AST return newAst; } var ast = { "type": "Program", "body": [ { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "a" }, "init": { "type": "Literal", "value": 1, "raw": "1" } } ], "kind": "const" } ], "sourceType": "script" } console.log(ast); console.log('轉換后的-------'); console.log(transformer(ast));
打印結果如下所示,可以看到,ES6的語法已經被轉換了,如下所示:
代碼生成
我們會根據上面生成的AST樹來生成一個很大的字符串當中。
基本代碼如下所示:
var newAst = { "type": "Program", "body": [ { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "a" }, "init": { "type": "Literal", "value": 1, "raw": "1" } } ], "kind": "var" } ], "sourceType": "script" } function codeGenerator(node) { console.log(node.type); // 對於不同類型的節點分開處理\ switch (node.type) { // 如果是Program節點,我們會遍歷它的body屬性中的每一個節點 case 'Program': return node.body.map(codeGenerator).join('\n'); // VariableDeclaration節點 case 'VariableDeclaration': return node.kind + ' ' + node.declarations.map(codeGenerator); // VariableDeclarator 節點 case "VariableDeclarator": return codeGenerator(node.id) + ' = ' + codeGenerator(node.init); // 處理變量 case 'Identifier': return node.name; // case 'Literal': return node.value; default: throw new TypeError(node.type); } } console.log(codeGenerator(newAst));
如上最后打印了 var a = 1; 如上就是js整個編譯的過程。
2. 運行階段
運行階段包括 預解析 和 運行階段。
2.1 預解析
如上我們已經編譯完成了,那么現在我們需要對js代碼進行預解析,那么什么是預解析呢,它的作用是什么呢?
預解析指的是:在js文件或script里面的代碼在正式開始執行之前,會進行一些解析工作。比如上在全局中尋找var關鍵字聲明的變量和通過function關鍵字聲明的函數。 找到全局變量或函數后,我們會對該進行作用域提升,但是在變量提升聲明的情況下不會賦值操作,因此它的默認值是undefined。通過聲明提升,對於函數來講,函數可以在聲明函數體之上進行調用。變量也可以在賦值之前進行輸出,只是變量輸出的值為undefined而已。
比如如下代碼:
var a = 1; function abc() { console.log(a); var a = 2; } abc();
如上代碼,我們在全局變量中定義了一個變量a = 1; 在函數abc中先打印a,然后給 var a = 2; 進行賦值,但是最后打印的結果為undefined; 那是因為var在作用域中有提升的。上面的代碼在預解析的時候,會被解析成如下代碼:
var a = 1; function abc() { var a; console.log(a); a = 2; } abc();
預編譯需要注意如下幾個問題:
1. 預編譯首先是全局預編譯,函數體未調用時是不進行預編譯的。
2. 只有var 和 function 聲明會被提升。
3. 在所在的作用域會被提升,不會擴展到其他的作用域。
4. 預編譯后會順序執行代碼。
2.2 運行階段
在瀏覽器環境中,javascript引擎會按照 <script>標簽代碼塊從上到下的順序加載並立即解釋執行。