当前位置: 首页 > 科技观察

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

时间:2023-03-22 16:41:54 科技观察

如果给你下面的代码片段(动态获取的代码串),让你在前端动态导入这个模块并执行里面的函数,你会怎么处理?module.exports={name:'ConardLi',action:function(){console.log(this.name);}};如果node环境的执行是在node环境下,我们可能很快就会想到使用Module模块。有一个私有函数_compile可以动态加载一个模块:exportfunctiongetRuleFromString(code){constmyModule=newModule('my-module');myModule._compile(code,'my-module');returnmyModule.exports;}实现就像这个很简单,后面我们会回顾一下_compile函数的原理,但是需求没有那么简单,如果我们想在前端环境中动态引入这段代码怎么办?嗯,你没听错,我最近刚好遇到这样一个需求,需要在前端和Node端理顺动态导入模块的逻辑,那我们就模仿Module模块实现一个JavaScript模块执行器吧前端环境。首先我们来回顾一下node.js中的模块加载原理。nodeModule模块加载Node.js的原理遵循CommonJS规范。这个规范的核心思想是让模块通过require方法同步加载自己依赖的其他模块,然后通过exports或者module.exports导出需要暴露的接口。它主要是为了解决JavaScript的作用域问题而定义的模块形式,允许每个模块在自己的命名空间中执行。在每个NodeJs模块中,我们可以获取module、exports、__dirname、__filename和require等模块。并且各个模块的执行范围相互隔离,互不影响。其实上面整个模块系统的核心就是Module类的_compile方法。直接看_compile的源码:Module.prototype._compile=function(content,filename){//去除Shebang代码content=internalModule.stripShebang(content);//1.创建包装器函数varwrapper=Module.wrap(content);//2.在当前上下文中编译模块的包装函数代码varcompiledWrapper=vm.runInThisContext(wrapper,{filename:filename,lineOffset:0,displayErrors:true});vardirname=path.dirname(filename);varrequire=internalModule.makeRequireFunction(这);vardepth=internalModule.requireDepth;//3.运行模块的package函数,传入module,exports,__dirname,__filename,requirevarresult=compiledWrapper.call(this.exports,this.exports,require,this,filename,dirname);returnresult;};我就把整个执行过程分为三步:创建一个包装函数第一步调用Module内部的包装函数给模块封装原来的内容,我们先看一下包装函数的实现:Module.wrap=function(script){returnModule.wrapper[0]+script+Module.wrapper[1];};Module.wrapper=['(function(exports,require,module,__filename,__dirname){','\n});'];CommonJS的主要目的是解决JavaScript的作用域问题,让每个模块都可以在自己的命名空间中执行。当没有模块化方案时,我们一般会创建一个自执行函数来避免变量污染:(function(global){//executecode..})(window)所以这一步很重要。首先,包装函数将模块本身的代码片段包装在一个函数作用域中,将我们需要使用的对象作为参数引入。所以上面的代码块变成了:});编译封装功能代码NodeJs中的vm模块提供了一系列API,用于在V8虚拟机环境下编译运行代码。JavaScript代码可以立即编译运行,也可以编译保存运行。vm.runInThisContext()在当前全局对象的上下文中编译和执行代码,最后返回结果。运行代码不能访问本地作用域,但可以访问当前的全局对象。varcompiledWrapper=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);}};});运行封装函数,最后通过调用Get可执行函数并传入相应的对象来执行编译。varresult=compiledWrapper.call(this.exports,this.exports,require,this,filename,dirname);所以你应该明白,我们在module中拿到的module是Module模块本身的实例,我们直接调用的exports其实是对module.exports的引用,所以我们可以同时使用module.exports和exports来导出一个模块。ImplementModule如果我们想在前端环境中执行一个CommonJS模块,那么只需要手动实现一个Module模块即可。重组上述过程。如果只考虑动态引入模块代码块的逻辑,我们可以抽象出如下代码:exportdefaultclassModule{exports={}wrapper=['return(function(exports,module){','\n});'];wrap(script){return`${this.wrapper[0]}${script}${this.wrapper[1]}`;};compile(content){constwrapper=this.wrap(content);constcompiledWrapper=vm.runInContext(wrapper);compiledWrapper.call(this.exports,this.exports,this);}}这里有个问题,浏览器环境中没有VM模块,VM会将代码加载到上下文环境中,把它放到一个沙箱(sandbox)中,让代码的整个运行都在一个封闭的上下文环境中执行,我们需要自己实现一个浏览器环境的沙箱。实现浏览器沙箱eval在浏览器中执行一段代码。我们首先想到的可能是eval。eval函数可以将Javascript字符串作为代码片段执行。但是,由eval()执行的代码可以访问闭包和全局范围,这可能会导致称为代码注入的安全风险。Eval虽然有用,但经常被滥用,并且是JavaScript最臭名昭著的特性之一。一。所以,已经有很多替代方案可以在沙箱中而不是在全局范围内执行字符串代码值。newFunction()Function构造函数是eval()的替代方法。newFunction(...args,'funcBody')评估传递的'funcBody'字符串并返回执行此代码的函数。fn=newFunction(...args,'functionBody');返回的fn是定义好的函数,最后一个参数是函数体。它和eval有两点不同:fn是编译后的代码,可以直接执行,而eval需要编译一次。fn无法访问闭包的作用域,但它仍然可以访问全局作用域但这仍然无法解决访问全局作用域的问题。with关键字with是JavaScript中不受欢迎的关键字。它允许半沙盒运行时环境。with块内的代码将首先尝试从传入的沙箱对象中获取变量,但如果找不到,它将在闭包和全局范围内查找。使用newFunction()可以避免闭包范围访问,因此我们只需要处理全局范围。with在内部使用in运算符。块中访问的每个变量将使用沙箱条件中的变量进行判断。如果条件为真,则从沙箱对象中读取变量。否则,它会在全局范围内查找变量。functioncompileCode(src){src='with(sandbox){'+src+'}'returnnewFunction('sandbox',src)}试想一下,如果沙箱条件中的变量一直为真,沙箱环境永远不会被读取环境变量?所以我们需要劫持沙盒对象的属性,让所有的属性都可以一直被读取。ProxyES6提供了一个Proxy函数,它是访问对象之前的一个拦截器。我们可以使用Proxy来拦截沙盒属性,这样所有的属性都可以被读取:functioncompileCode(code){code='with(sandbox){'+code+'}';constfn=newFunction('sandbox',code);return(sandbox)=>{constproxy=newProxy(sandbox,{has(){returntrue;}});returnfn(proxy);}}Symbol.unscopablesSymbol.unscopables是一个众所周知的符号。众所周知的标记是一种内置的JavaScript符号,可用于表示内部语言行为。Symbol.unscopables定义了对象的不可作用域(非限定)属性。在with语句中,Unscopable属性不能从Sandbox对象中检索,而是直接从闭包或全局范围中检索。所以我们需要加强Symbol.unscopables的情况,functioncompileCode(code){code='with(sandbox){'+code+'}';constfn=newFunction('sandbox',code);return(sandbox)=>{constproxy=newProxy(sandbox,{has(){returntrue;},get(target,key,receiver){if(key===Symbol.unscopables){returnundefined;}Reflect.get(target,key,receiver);}});returnfn(proxy);}}全局变量白名单但是,此时浏览器默认提供的各种工具类和函数在沙箱中是无法执行的,只能作为工具使用,没有任何sideeffects纯函数,当我们要使用一些全局变量或者类的时候,我们可以自定义一个白名单:constALLOW_LIST=['console'];functioncompileCode(code){code='with(sandbox){'+code+'}';constfn=newFunction('sandbox',code);return(sandbox)=>{constproxy=newProxy(sandbox,{has(){if(!ALLOW_LIST.includes(key)){returntrue;}},get(target,key,receiver){if(key===Symbol.unscopables){returnundefined;}Reflect.get(target,key,receiver);}});returnfn(proxy);}}最终代码:好了,总结一下上面的代码,我们完成了一个简单的JavaScript模块执行器:constALLOW_LIST=['console'];exportdefaultclassModule{exports={}wrapper=['return(function(exports,模块){','\n});'];wrap(script){return`${this.wrapper[0]}${script}${this.wrapper[1]}`;};runInContext(code){code=`with(sandbox){${code}}`;constfn=newFunction('sandbox',code);return(沙盒)=>{constproxy=newProxy(sandbox,{has(target,key){if(!ALLOW_LIST.includes(key)){returntrue;}},get(target,key,receiver){if(key===Symbol.unscopables){returnundefined;}Reflect.get(target,key,receiver);}});returnfn(proxy);}}compile(content){constwrapper=this.wrap(content);constcompiledWrapper=this.runInContext(wrapper)({});compiledWrapper.call(this.exports,this.exports,this);}}测试执行结果:functiongetModuleFromString(code){constscanModule=newModule();scanModule.compile(code);returnscanModule.exports;}constmodule=getModuleFromString(`module.exports={name:'ConardLi',action:function(){console.log(this.name);}};`);module.action();//ConardLi