最近在研究 AST, 之前有一篇文章 面試官: 你瞭解過 Babel 嗎?寫過 Babel 外掛嗎? 答: 沒有。卒 為什麼要去了解它? 因為懂得 AST 真的可以為所欲為
簡單點說,使用 Javascript 執行Javascript程式碼。
這篇文章來告訴你,如何寫一個最簡單的解析器。
前言(如果你很清楚如何執行自定義 js 程式碼,請跳過)
在大家的認知中,有幾種執行自定義指令碼的方法?我們來列舉一下:
Web
建立 script 指令碼,並插入文件流
function runJavascriptCode(code) {
const script = document.createElement("script");
script.innerText = code;
document.body.appendChild(script);
}
runJavascriptCode("alert('hello world')");
複製程式碼
eval
無數人都在說,不要使用eval
,雖然它可以執行自定義指令碼
eval("alert('hello world')");
複製程式碼
參考連結: Why is using the JavaScript eval function a bad idea?
setTimeout
setTimeout 同樣能執行,不過會把相關的操作,推到下一個事件迴圈中執行
setTimeout("console.log('hello world')");
console.log("I should run first");
// 輸出
// I should run first
// hello world'
複製程式碼
new Function
new Function("alert('hello world')")();
複製程式碼
參考連結: Are eval() and new Function() the same thing?
NodeJs
require
可以把 Javascript 程式碼寫進一個 Js 檔案,然後在其他檔案 require 它,達到執行的效果。
NodeJs 會快取模組,如果你執行 N 個這樣的檔案,可能會消耗很多記憶體. 需要執行完畢後,手動清除快取。
Vm
const vm = require("vm");
const sandbox = {
animal: "cat",
count: 2
};
vm.runInNewContext('count += 1; name = "kitty"', sandbox);
複製程式碼
以上方式,除了 Node 能優雅的執行以外,其他都不行,API 都需要依賴宿主環境。
直譯器用途
在能任何執行 Javascript 的程式碼的平臺,執行自定義程式碼。
比如小程式,遮蔽了以上執行自定義程式碼的途徑
那就真的不能執行自定義程式碼了嗎?
非也
工作原理
基於 AST(抽象語法樹),找到對應的物件/方法, 然後執行對應的表示式。
這怎麼說的有點繞口呢,舉個栗子console.log("hello world");
原理: 通過 AST 找到console
物件,再找到它log
函式,最後執行函式,引數為hello world
準備工具
- Babylon, 用於解析程式碼,生成 AST
- babel-types, 判斷節點型別
- astexplorer, 隨時檢視抽象語法樹
開始擼程式碼
我們以執行console.log("hello world")
為例
開啟astexplorer, 檢視對應的 AST
由圖中看到,我們要找到console.log("hello world")
,必須要向下遍歷節點的方式,經過File
、Program
、ExpressionStatement
、CallExpression
、MemberExpression
節點,其中涉及到Identifier
、StringLiteral
節點
我們先定義visitors
, visitors
是對於不同節點的處理方式
const visitors = {
File(){},
Program(){},
ExpressionStatement(){},
CallExpression(){},
MemberExpression(){},
Identifier(){},
StringLiteral(){}
};
複製程式碼
再定義一個遍歷節點的函式
/**
* 遍歷一個節點
* @param {Node} node 節點物件
* @param {*} scope 作用域
*/
function evaluate(node, scope) {
const _evalute = visitors[node.type];
// 如果該節點不存在處理函式,那麼丟擲錯誤
if (!_evalute) {
throw new Error(`Unknown visitors of ${node.type}`);
}
// 執行該節點對應的處理函式
return _evalute(node, scope);
}
複製程式碼
下面是對各個節點的處理實現
const babylon = require("babylon");
const types = require("babel-types");
const visitors = {
File(node, scope) {
evaluate(node.program, scope);
},
Program(program, scope) {
for (const node of program.body) {
evaluate(node, scope);
}
},
ExpressionStatement(node, scope) {
return evaluate(node.expression, scope);
},
CallExpression(node, scope) {
// 獲取呼叫者物件
const func = evaluate(node.callee, scope);
// 獲取函式的引數
const funcArguments = node.arguments.map(arg => evaluate(arg, scope));
// 如果是獲取屬性的話: console.log
if (types.isMemberExpression(node.callee)) {
const object = evaluate(node.callee.object, scope);
return func.apply(object, funcArguments);
}
},
MemberExpression(node, scope) {
const { object, property } = node;
// 找到對應的屬性名
const propertyName = property.name;
// 找對對應的物件
const obj = evaluate(object, scope);
// 獲取對應的值
const target = obj[propertyName];
// 返回這個值,如果這個值是function的話,那麼應該繫結上下文this
return typeof target === "function" ? target.bind(obj) : target;
},
Identifier(node, scope) {
// 獲取變數的值
return scope[node.name];
},
StringLiteral(node) {
return node.value;
}
};
function evaluate(node, scope) {
const _evalute = visitors[node.type];
if (!_evalute) {
throw new Error(`Unknown visitors of ${node.type}`);
}
// 遞迴呼叫
return _evalute(node, scope);
}
const code = "console.log('hello world')";
// 生成AST樹
const ast = babylon.parse(code);
// 解析AST
// 需要傳入執行上下文,否則找不到``console``物件
evaluate(ast, { console: console });
複製程式碼
在 Nodejs 中執行試試看
$ node ./index.js
hello world
複製程式碼
然後我們更改下執行的程式碼 const code = "console.log(Math.pow(2, 2))";
因為上下文沒有Math
物件,那麼會得出這樣的錯誤 TypeError: Cannot read property 'pow' of undefined
記得傳入上下文evaluate(ast, {console, Math});
再執行,又得出一個錯誤Error: Unknown visitors of NumericLiteral
原來Math.pow(2, 2)
中的 2,是數字字面量
節點是NumericLiteral
, 但是在visitors
中,我們卻沒有定義這個節點的處理方式.
那麼我們就加上這麼個節點:
NumericLiteral(node){
return node.value;
}
複製程式碼
再次執行,就跟預期結果一致了
$ node ./index.js
4
複製程式碼
到這裡,已經實現了最最基本的函式呼叫了
進階
既然是直譯器,難道只能執行 hello world 嗎?顯然不是
我們來宣告個變數吧
var name = "hello world";
console.log(name);
複製程式碼
先看下 AST 結構
visitors
中缺少VariableDeclaration
和VariableDeclarator
節點的處理,我們給加上
VariableDeclaration(node, scope) {
const kind = node.kind;
for (const declartor of node.declarations) {
const {name} = declartor.id;
const value = declartor.init
? evaluate(declartor.init, scope)
: undefined;
scope[name] = value;
}
},
VariableDeclarator(node, scope) {
scope[node.id.name] = evaluate(node.init, scope);
}
複製程式碼
執行下程式碼,已經列印出hello world
我們再來宣告函式
function test() {
var name = "hello world";
console.log(name);
}
test();
複製程式碼
根據上面的步驟,新增了幾個節點
BlockStatement(block, scope) {
for (const node of block.body) {
// 執行程式碼塊中的內容
evaluate(node, scope);
}
},
FunctionDeclaration(node, scope) {
// 獲取function
const func = visitors.FunctionExpression(node, scope);
// 在作用域中定義function
scope[node.id.name] = func;
},
FunctionExpression(node, scope) {
// 自己構造一個function
const func = function() {
// TODO: 獲取函式的引數
// 執行程式碼塊中的內容
evaluate(node.body, scope);
};
// 返回這個function
return func;
}
複製程式碼
然後修改下CallExpression
// 如果是獲取屬性的話: console.log
if (types.isMemberExpression(node.callee)) {
const object = evaluate(node.callee.object, scope);
return func.apply(object, funcArguments);
} else if (types.isIdentifier(node.callee)) {
// 新增
func.apply(scope, funcArguments); // 新增
}
複製程式碼
執行也能過列印出hello world
其他
限於篇幅,我不會講怎麼處理所有的節點,以上已經講解了基本的原理。
對於其他節點,你依舊可以這麼來,其中需要注意的是: 上文中,作用域我統一用了一個 scope,沒有父級/子級作用域之分
也就意味著這樣的程式碼是可以執行的
var a = 1;
function test() {
var b = 2;
}
test();
console.log(b); // 2
複製程式碼
處理方法: 在遞迴 AST 樹的時候,遇到一些會產生子作用域的節點,應該使用新的作用域,比如說function
,for in
等
最後
以上只是一個簡單的模型,它連玩具都算不上,依舊有很多的坑。比如:
- 變數提升, 作用域應該有預解析階段
- 作用域有很多問題
- 特定節點,必須巢狀在某節點下。比如 super()就必須在 Class 節點內,無論巢狀多少層
- this 繫結
- ...
連續幾個晚上的熬夜之後,我寫了一個比較完善的庫vm.js,基於jsjs修改而來,站在巨人的肩膀上。
與它不同的是:
- 重構了遞迴方式,解決了一些沒法解決的問題
- 修復了多項 bug
- 新增了測試用例
- 支援 es6 以及其他語法糖
目前正在開發中, 等待更加完善之後,會發布第一個版本。
歡迎大佬們拍磚和 PR.
小程式今後變成大程式,業務程式碼通過 Websocket 推送過來執行,小程式原始碼只是一個空殼,想想都刺激.
專案地址: github.com/axetroy/vm.…
線上預覽: axetroy.github.io/vm.js/