手動實現(xiàn)一個 JavaScript 模塊執(zhí)行器
如果給你下面這樣一個代碼片段(動態(tài)獲取的代碼字符串),讓你在前端動態(tài)引入這個模塊并執(zhí)行里面的函數(shù),你會如何處理呢?
- module.exports = {
- name : 'ConardLi',
- action : function(){
- console.log(this.name);
- }
- };
node 環(huán)境的執(zhí)行
如果在 node 環(huán)境,我們可能會很快的想到使用 Module 模塊, Module 模塊中有一個私有函數(shù) _compile,可以動態(tài)的加載一個模塊:
- export function getRuleFromString(code) {
- const myModule = new Module('my-module');
- myModule._compile(code,'my-module');
- return myModule.exports;
- }
實現(xiàn)就是這么簡單,后面我們會回顧一下 _compile 函數(shù)的原理,但是需求可不是這么簡單,我們?nèi)绻谇岸谁h(huán)境動態(tài)引入這段代碼呢?
嗯,你沒聽錯,最近正好碰到了這樣的需求,需要在前端和 Node 端抹平動態(tài)引入模塊的邏輯,好,下面我們來模仿 Module 模塊實現(xiàn)一個前端環(huán)境的 JavaScript 模塊執(zhí)行器。
首先我們先來回顧一下 node 中的模塊加載原理。
node Module 模塊加載原理
Node.js 遵循 CommonJS 規(guī)范,該規(guī)范的核心思想是允許模塊通過 require 方法來同步加載所要依賴的其他模塊,然后通過 exports 或 module.exports 來導(dǎo)出需要暴露的接口。其主要是為了解決 JavaScript 的作用域問題而定義的模塊形式,可以使每個模塊它自身的命名空間中執(zhí)行。
再在每個 NodeJs 模塊中,我們都能取到 module、exports、__dirname、__filename 和 require 這些模塊。并且每個模塊的執(zhí)行作用域都是相互隔離的,互不影響。
其實上面整個模塊系統(tǒng)的核心就是 Module 類的 _compile 方法,我們直接來看 _compile 的源碼:
- Module.prototype._compile = function(content, filename) {
- // 去除 Shebang 代碼
- content = internalModule.stripShebang(content);
- // 1.創(chuàng)建封裝函數(shù)
- var wrapper = Module.wrap(content);
- // 2.在當(dāng)前上下文編譯模塊的封裝函數(shù)代碼
- var compiledWrapper = vm.runInThisContext(wrapper, {
- filename: filename,
- lineOffset: 0,
- displayErrors: true
- });
- var dirname = path.dirname(filename);
- var require = internalModule.makeRequireFunction(this);
- var depth = internalModule.requireDepth;
- // 3.運行模塊的封裝函數(shù)并傳入 module、exports、__dirname、__filename、require
- var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
- return result;
- };
整個執(zhí)行過程我將其分為三步:
創(chuàng)建封裝函數(shù)
第一步即調(diào)用 Module 內(nèi)部的 wrapper 函數(shù)對模塊的原始內(nèi)容進行封裝,我們先來看看 wrapper 函數(shù)的實現(xiàn):
- Module.wrap = function(script) {
- return Module.wrapper[0] + script + Module.wrapper[1];
- };
- Module.wrapper = [
- '(function (exports, require, module, __filename, __dirname) { ',
- '\n});'
- ];
CommonJS 的主要目的就是解決 JavaScript 的作用域問題,可以使每個模塊它自身的命名空間中執(zhí)行。在沒有模塊化方案的時候,我們一般會創(chuàng)建一個自執(zhí)行函數(shù)來避免變量污染:
- (function(global){
- // 執(zhí)行代碼。。
- })(window)
所以這一步至關(guān)重要,首先 wrapper 函數(shù)就將模塊本身的代碼片段包裹在一個函數(shù)作用域內(nèi),并且將我們需要用到的對象作為參數(shù)引入。所以上面的代碼塊被包裹后就變成了:
- (function (exports, require, module, __filename, __dirname) {
- module.exports = {
- name : 'ConardLi',
- action : function(){
- console.log(this.name);
- }
- };
- });
編譯封裝函數(shù)代碼
NodeJs 中的 vm 模塊提供了一系列 API 用于在 V8 虛擬機環(huán)境中編譯和運行代碼。JavaScript 代碼可以被編譯并立即運行,或編譯、保存然后再運行。
vm.runInThisContext() 在當(dāng)前的 global 對象的上下文中編譯并執(zhí)行 code,最后返回結(jié)果。運行中的代碼無法獲取本地作用域,但可以獲取當(dāng)前的 global 對象。
- var compiledWrapper = vm.runInThisContext(wrapper, {
- filename: filename,
- lineOffset: 0,
- displayErrors: true
- });
所以以上代碼執(zhí)行后,就將代碼片段字符串編譯成了一個真正的可執(zhí)行函數(shù):
- (function (exports, require, module, __filename, __dirname) {
- module.exports = {
- name : 'ConardLi',
- action : function(){
- console.log(this.name);
- }
- };
- });
運行封裝函數(shù)
最后通過 call 來執(zhí)行編譯得到的可執(zhí)行函數(shù),并傳入對應(yīng)的對象。
- var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
所以看到這里你應(yīng)該會明白,我們在模塊中拿到的 module,就是 Module 模塊的實例本身,我們直接調(diào)用的 exports 實際上是 module.exports 的引用,所以我們既可以使用 module.exports 也可以使用 exports 來導(dǎo)出一個模塊。
實現(xiàn) Module 模塊
如果我們想在前端環(huán)境執(zhí)行一個 CommonJS 模塊,那么我們只需要手動實現(xiàn)一個 Module 模塊就好了,重新梳理上面的流程,如果只考慮模塊代碼塊動態(tài)引入的邏輯,我們可以抽象出下面的代碼:
- export default class Module {
- exports = {}
- wrapper = [
- 'return (function (exports, module) { ',
- '\n});'
- ];
- wrap(script) {
- return `${this.wrapper[0]} ${script} ${this.wrapper[1]}`;
- };
- compile(content) {
- const wrapper = this.wrap(content);
- const compiledWrapper = vm.runInContext(wrapper);
- compiledWrapper.call(this.exports, this.exports, this);
- }
- }
這里有個問題,在瀏覽器環(huán)境是沒有 VM 這個模塊的,VM 會將代碼加載到一個上下文環(huán)境中,置入沙箱(sandbox),讓代碼的整個操作執(zhí)行都在封閉的上下文環(huán)境中進行,我們需要自己實現(xiàn)一個瀏覽器環(huán)境的沙箱。
實現(xiàn)瀏覽器沙箱
eval
在瀏覽器執(zhí)行一段代碼片段,我們首先想到的可能就是 eval, eval 函數(shù)可以將一個 Javascript 字符串視作代碼片段執(zhí)行。
但是,由 eval() 執(zhí)行的代碼能夠訪問閉包和全局作用域,這會導(dǎo)致被稱為代碼注入 code injection 的安全隱患, eval 雖然好用,但是經(jīng)常被濫用,是 JavaScript 最臭名昭著的功能之一。
所以,后來又出現(xiàn)了很多在沙箱而非全局作用域中的執(zhí)行字符串代碼的值的替代方案。
new Function()
Function 構(gòu)造器是 eval() 的一個替代方案。new Function(...args, 'funcBody') 對傳入的 'funcBody' 字符串進行求值,并返回執(zhí)行這段代碼的函數(shù)。
- fn = new Function(...args, 'functionBody');
返回的 fn 是一個定義好的函數(shù),最后一個參數(shù)為函數(shù)體。它和 eval 有兩點區(qū)別:
- fn 是一段編譯好的代碼,可以直接執(zhí)行,而 eval 需要編譯一次
- fn 沒有對所在閉包的作用域訪問權(quán)限,不過它依然能夠訪問全局作用域
但是這仍然不能解決訪問全局作用域的問題。
with 關(guān)鍵詞
with 是 JavaScript 一個冷門的關(guān)鍵字。它允許一個半沙箱的運行環(huán)境。with 代碼塊中的代碼會首先試圖從傳入的沙箱對象獲得變量,但是如果沒找到,則會在閉包和全局作用域中尋找。閉包作用域的訪問可以用new Function() 來避免,所以我們只需要處理全局作用域。with 內(nèi)部使用 in 運算符。在塊中訪問每個變量,都會使用 variable in sandbox 條件進行判斷。若條件為真,則從沙箱對象中讀取變量。否則,它會在全局作用域中尋找變量。
- function compileCode(src) {
- src = 'with (sandbox) {' + src + '}'
- return new Function('sandbox', src)
- }
試想,如果 variable in sandbox 條件永遠為真,沙箱環(huán)境不就永遠也讀取不到環(huán)境變量了嗎?所以我們需要劫持沙箱對象的屬性,讓所有的屬性永遠都能讀取到。
Proxy
ES6 中提供了一個 Proxy 函數(shù),它是訪問對象前的一個攔截器,我們可以利用 Proxy 來攔截 sandbox 的屬性,讓所有的屬性都可以讀取到:
- function compileCode(code) {
- code = 'with (sandbox) {' + code + '}';
- const fn = new Function('sandbox', code);
- return (sandbox) => {
- const proxy = new Proxy(sandbox, {
- has() {
- return true;
- }
- });
- return fn(proxy);
- }
- }
Symbol.unscopables
Symbol.unscopables 是一個著名的標記。一個著名的標記即是一個內(nèi)置的 JavaScript Symbol,它可以用來代表內(nèi)部語言行為。
Symbol.unscopables 定義了一個對象的 unscopable(不可限定)屬性。在 with 語句中,不能從 Sandbox 對象中檢索 Unscopable 屬性,而是直接從閉包或全局作用域檢索屬性。
所以我們需要對 Symbol.unscopables 這種情況做一次加固,
- function compileCode(code) {
- code = 'with (sandbox) {' + code + '}';
- const fn = new Function('sandbox', code);
- return (sandbox) => {
- const proxy = new Proxy(sandbox, {
- has() {
- return true;
- },
- get(target, key, receiver) {
- if (key === Symbol.unscopables) {
- return undefined;
- }
- Reflect.get(target, key, receiver);
- }
- });
- return fn(proxy);
- }
- }
全局變量白名單
但是,這時沙箱里是執(zhí)行不了瀏覽器默認為我們提供的各種工具類和函數(shù)的,它只能作為一個沒有任何副作用的純函數(shù),當(dāng)我們想要使用某些全局變量或類時,可以自定義一個白名單:
- const ALLOW_LIST = ['console'];
- function compileCode(code) {
- code = 'with (sandbox) {' + code + '}';
- const fn = new Function('sandbox', code);
- return (sandbox) => {
- const proxy = new Proxy(sandbox, {
- has() {
- if (!ALLOW_LIST.includes(key)) {
- return true;
- }
- },
- get(target, key, receiver) {
- if (key === Symbol.unscopables) {
- return undefined;
- }
- Reflect.get(target, key, receiver);
- }
- });
- return fn(proxy);
- }
- }
最終代碼:
好了,總結(jié)上面的代碼,我們就完成了一個簡易的 JavaScript 模塊執(zhí)行器:
- const ALLOW_LIST = ['console'];
- export default class Module {
- exports = {}
- wrapper = [
- 'return (function (exports, module) { ',
- '\n});'
- ];
- wrap(script) {
- return `${this.wrapper[0]} ${script} ${this.wrapper[1]}`;
- };
- runInContext(code) {
- code = `with (sandbox) { ${code} }`;
- const fn = new Function('sandbox', code);
- return (sandbox) => {
- const proxy = new Proxy(sandbox, {
- has(target, key) {
- if (!ALLOW_LIST.includes(key)) {
- return true;
- }
- },
- get(target, key, receiver) {
- if (key === Symbol.unscopables) {
- return undefined;
- }
- Reflect.get(target, key, receiver);
- }
- });
- return fn(proxy);
- }
- }
- compile(content) {
- const wrapper = this.wrap(content);
- const compiledWrapper = this.runInContext(wrapper)({});
- compiledWrapper.call(this.exports, this.exports, this);
- }
- }
測試執(zhí)行效果:
- function getModuleFromString(code) {
- const scanModule = new Module();
- scanModule.compile(code);
- return scanModule.exports;
- }
- const module = getModuleFromString(`
- module.exports = {
- name : 'ConardLi',
- action : function(){
- console.log(this.name);
- }
- };
- `);
- module.action(); // ConardLi