如何手动实现一个 JavaScript 模块执行器

作者:夜航船时间:2026-10-23 22:39:15浏览:665

本文详解如何在前端手动实现一个兼容 Node.js 的 JavaScript 模块执行器。首先解析 Node.js Module 模块中 _compile 方法的封装、编译与执行三步原理,随后针对浏览器环境无 VM 模块的问题,依次对比 eval、Function 构造器、with 语句及 Proxy 拦截器的优劣,最终结合 Symbol.unscopables 与全局白名单构建安全的浏览器端沙箱执行环境。

手动实现 JavaScript 模块执行器

假设你有一段动态获取的代码字符串,需要在前端环境中动态引入该模块并执行其中的函数,你会如何处理?


module.exports = { 
 name : 'ConardLi', 
 action : function(){ 
  console.log(this.name); 
 } 
}; 

Node 环境的执行原理

Node 环境的执行

在 Node.js 环境中,我们可以利用 Module 模块中的私有函数 _compile 来动态加载模块。


export function getRuleFromString(code) { 
 const myModule = new Module('my-module'); 
 myModule._compile(code,'my-module'); 
 return myModule.exports; 
} 

实现过程看似简单,但后续需要回顾 _compile 函数的底层原理。然而实际需求更为复杂:如果要在前端环境中动态引入这段代码,该如何处理?

确实如此,近期遇到了此类需求,需要在前端和 Node 端统一动态引入模块的逻辑。接下来,我们将模仿 Node.js 的 Module 模块,手动实现一个适用于前端环境的 JavaScript 模块执行器。

首先,我们需要回顾 Node.js 中模块加载的核心原理。

Node Module 模块加载原理

Node.js 遵循 CommonJS 规范,其核心思想是通过 require 方法同步加载依赖模块,并通过 exports 或 module.exports 导出接口。该规范旨在解决 JavaScript 的作用域问题,确保每个模块在独立的命名空间中执行。

在每个 Node.js 模块中,都可以访问 module、exports、__dirname、__filename 和 require 等变量。每个模块的执行作用域相互隔离,互不干扰。

整个模块系统的核心在于 Module 类的 _compile 方法,我们直接查看其源码。


Module.prototype._compile = function(content, filename) { 
 // 去除 Shebang 代码 
 content = internalModule.stripShebang(content); 
 
 // 1.创建封装函数 
 var wrapper = Module.wrap(content); 
 
 // 2.在当前上下文编译模块的封装函数代码 
 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.运行模块的封装函数并传入 module、exports、__dirname、__filename、require 
 var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname); 
 return result; 
}; 

整个执行过程可以概括为三个步骤:

创建封装函数

第一步是调用 Module 内部的 wrapper 函数对模块原始内容进行封装。我们先来看 wrapper 函数的具体实现。


Module.wrap = function(script) { 
 return Module.wrapper[0] + script + Module.wrapper[1]; 
}; 
 
Module.wrapper = [ 
 '(function (exports, require, module, __filename, __dirname) { ', 
 '\n});' 
]; 

CommonJS 的主要目的是解决 JavaScript 的作用域问题,使每个模块在独立的命名空间中执行。在没有模块化方案时,我们通常通过创建自执行函数来避免变量污染。


(function(global){ 
 // 执行代码。。 
})(window) 

因此,这一步至关重要。wrapper 函数将模块代码包裹在一个函数作用域内,并将所需对象作为参数传入。上述代码块被封装后变为:


(function (exports, require, module, __filename, __dirname) { 
 module.exports = { 
  name : 'ConardLi', 
  action : function(){ 
   console.log(this.name); 
  } 
 }; 
}); 

编译封装函数代码

Node.js 中的 vm 模块提供了一系列 API,用于在 V8 虚拟机环境中编译和运行代码。JavaScript 代码可以编译后立即运行,或编译保存后再运行。

vm.runInThisContext() 在当前 global 对象的上下文中编译并执行代码,最后返回结果。运行中的代码无法获取本地作用域,但可以访问当前的 global 对象。


var compiledWrapper = vm.runInThisContext(wrapper, { 
 filename: filename, 
 lineOffset: 0, 
 displayErrors: true 
}); 

因此,上述代码执行后,将代码片段字符串编译为一个真正的可执行函数:


(function (exports, require, module, __filename, __dirname) { 
 module.exports = { 
  name : 'ConardLi', 
  action : function(){ 
   console.log(this.name); 
  } 
 }; 
}); 

运行封装函数

最后,通过 call 方法执行编译得到的可执行函数,并传入相应的对象参数。


var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname); 

至此你应该明白,模块中获取的 module 即为 Module 模块的实例本身,直接调用的 exports 实际上是 module.exports 的引用。因此,我们可以使用 module.exports 或 exports 来导出模块。

前端 Module 模块实现

实现 Module 模块

若要在前端环境执行 CommonJS 模块,只需手动实现一个 Module 模块。重新梳理上述流程,仅考虑模块代码块动态引入的逻辑,我们可以抽象出以下代码:


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); 
 } 
} 

这里存在一个问题:浏览器环境中没有 VM 模块。VM 会将代码加载到上下文环境中并置入沙箱(sandbox),使代码操作在封闭的上下文中进行。我们需要自行实现一个浏览器环境的沙箱。

浏览器沙箱实现方案

实现浏览器沙箱

eval

在浏览器中执行代码片段,首先想到的可能是 eval。eval 函数可以将 JavaScript 字符串视为代码片段执行。

然而,由 eval() 执行的代码可以访问闭包和全局作用域,这会导致代码注入(code injection)的安全隐患。eval 虽然好用,但常被滥用,是 JavaScript 中臭名昭著的功能之一。

因此,随后出现了许多在沙箱而非全局作用域中执行字符串代码的替代方案。

new Function()

Function 构造器是 eval() 的替代方案。new Function(...args, 'funcBody') 对传入的 'funcBody' 字符串进行求值,并返回执行这段代码的函数。


fn = new Function(...args, 'functionBody'); 

返回的 fn 是一个定义好的函数,最后一个参数为函数体。它与 eval 有两点区别:

  • fn 是一段编译好的代码,可以直接执行,而 eval 需要每次编译。
  • fn 没有对所在闭包的作用域访问权限,但它依然能够访问全局作用域。

但这仍然无法解决访问全局作用域的问题。

with 关键词

手动实现 JavaScript 模块执行器

with 是 JavaScript 中一个冷门的关键字。它允许一个半沙箱的运行环境。with 代码块中的代码会首先尝试从传入的沙箱对象获取变量,如果未找到,则会在闭包和全局作用域中查找。闭包作用域的访问可以通过 new Function() 来避免,因此我们只需处理全局作用域。with 内部使用 in 运算符。在块中访问每个变量时,都会使用 variable in sandbox 条件进行判断。若条件为真,则从沙箱对象中读取变量;否则,它会在全局作用域中查找。


function compileCode(src) { 
 src = 'with (sandbox) {' + src + '}' 
 return new Function('sandbox', src) 
} 

试想,如果 variable in sandbox 条件永远为真,沙箱环境不就永远读取不到环境变量了吗?因此,我们需要劫持沙箱对象的属性,使所有属性都能被读取。

Proxy

手动实现 JavaScript 模块执行器

ES6 中提供了 Proxy 函数,它是访问对象前的一个拦截器。我们可以利用 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 是一个著名的标记。一个著名的标记即是一个内置的 JavaScript Symbol,它可以用来代表内部语言行为。

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); 
 } 
} 

全局变量白名单

此时,沙箱中无法执行浏览器默认为我们提供的各种工具类和函数,它只能作为一个没有任何副作用的纯函数。当我们想要使用某些全局变量或类时,可以自定义一个白名单:


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); 
 } 
} 

最终代码:

总结上述代码,我们就完成了一个简易的 JavaScript 模块执行器:


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); 
 } 
} 

测试执行效果:


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 

以上就是如何手动实现一个 JavaScript 模块执行器的详细内容,更多关于 JavaScript 模块执行器的资料请关注本站其它相关文章!

作者最新文章