低代码的神?eval和new Function

903 阅读11分钟

1. 导言

最近笔者在做一个公式计算器的项目,大概是将用户输入的内容和选择的计算因子进行运算,翻来覆去,怎么做既简单又方便呢,最终看上了evalnew Function

2. eval

2.1 什么是eval

根据MDN文档描述

eval函数会将传入的字符串当作javascript代码进行执行。eval(string),如果参数是一个字符串,eval会对表达式进行求值,如果不是字符串,将直接返回;

简单演示一下示例(浏览器端运行):

    eval('5+9');//14
    eval('5+9;4+2');//6
    eval('location');//location对象
    eval({a:1});//{a:1}
    eval('let a=1');//undefined
    eval('let a=1;a');//1

通过以上几个示例我们可以发现,eval会执行字符串中的代码,并返回最后一个表达式的值;当传入非字符串类型时会原封不动的返回;传入没有返回值的语句时会返回undefined;

2.2 eval作用域

eval除了可以访问局部变量,全局变量所在的作用域外,自己还会创建一个eval作用域。我们来看一段代码:

        let a1 = 1;
        var a2 = 2;
        function test() {
            let a3 = 3;
            eval('let a4=4;console.log("a1,a2,a3,a4",a1,a2,a3,a4);debugger;');
            
        }
        test()

打印结果为 a1,a2,a3,a4 1 2 3 4 ;这意味着上面的所有变量所在的作用域都可以访问得到;那么a4这个变量会在哪个作用域呢,根据调试结果

​​​​​​​在这里插入图片描述

发现,eval会单独的创建一个eval作用域来保存这eval里面的声明,在eval外面无法访问到该变量。那接下来试着改变a3的值看能否成功,

        let a1 = 1;
        var a2 = 2;
        function test() {
            let a3 = 3;
            eval('let a4=4;console.log("a1,a2,a3,a4",a1,a2,a3,a4);a3=33;');
            console.log('a3',a3)
        }
        test()
a3 33

也的确如此,a3的值直接变成了33;不过这也是eval带来的问题之一,如果将eval内部的参数输入暴露给普通用户而不做语法检测之类的话,可能会造成修改局部变量,影响程序运行等问题

2.3 eval问题

2.3.1 通过访问作用域修改局部变量

在上一小节说过eval可以访问eval被调用的全部作用域,当输入修改代码时可以修改生效导致程序发生意想不到的报错;对于这点,我们可以用间接调用的方式将eval访问的作用域限制到全局作用域中( ECMAScript 5规范),通过用一个变量保存对eval的引用来实现

        let a1 = 1;
        var a2 = 2;
        function test() {
            let a3 = 3;
            let eval_1=eval;
            eval_1('var a5=6;let a4=4;debugger;console.log("a1,a2,a3,a4",a1,a2,a3,a4);');//这里会报错,eval内部访问不到a3
            console.log('a3',a3) 
        }
        test()

eval简介调用 调试观察其作用域发现,a3不见了,另外在eval内部声明的var a5=6;挂在了全局作用域上而不是函数test形成的作用域也证明了这一点,当然如果想要a5挂在eval作用域,在内部加上"use strict"即可;另外简便一点的写法是 (1,eval)('xxxx'),大致原理是(x,x,x)从左到右解析并返回最后一个操作数的值,这里1和eval最后返回了eval的引用,执行后运行在全局作用域。

2.3.2 破坏编译优化

MDN文档提到,

现代 JavaScript 解释器将 JavaScript 转换为机器代码。这意味着任何变量命名的概念都会被删除。因此,任意一个 eval 的使用都会强制浏览器进行冗长的变量名称查找,以确定变量在机器代码中的位置并设置其值。另外,新内容将会通过 eval() 引进给变量,比如更改该变量的类型,因此会强制浏览器重新执行所有已经生成的机器代码以进行补偿

可以这么理解,现代js引擎采用JIT(即时编译)的技术将js转换为机器码,这个过程通常涉及将源代码转换为AST抽象语法树,然后通过解释器快速生成字节码,接着对高频热点代码编译成机器代码。在编译后的机器代码中变量名已经被优化成内存地址或者寄存器,直接通过映射获取。 而本文的eval参数是生成的动态字符串,引擎在编译时无法分析其内部内容,因此在运行时需要重新解析,通过作用域动态的查找参数中的值,确保这个值确实在eval可以访问的作用域中,无法做到编译优化。如果我们在eval中动态改变某个变量的类型或者新声明一些新的变量,比如将字符串类型改为数组类型,这将会使引擎废弃原本的以优化的机器码,生成新的机器码来补偿

2.3.3 增加内存开销

大家都知道闭包环境会保存所引用的变量在内存中,先看下面经典的闭包代码;

        function aa() {
            let a = 9;
            let kk = 3;
            console.log('kk', kk);
            const bb = () => {
                console.log('闭包里面的a', a);
                debugger;
            }
            return bb
        }
        let bb = aa();
        bb();

在浏览器中运行发现了 经典闭包 a 保存在了闭包环境中,在bb内通过闭包作用域closure访问到a,而kk这个变量由于没有在bb内被引用,因此在这个闭包作用域也不会保存kk这个变量。那如果我们加上eval呢,

    function aa() {
            let a = 9;
            let kk = 3;
            console.log('kk', kk);
            const bb = () => {
                console.log('闭包里面的a', a);
                eval('"use strict";let c=4;var d=5;aa();debugger;');

            }
            return bb
        }
        let bb = aa();
        bb();

在浏览器运行一下, eval加闭包 欸,我们发现在闭包作用域内保存了kk这个变量,再细心点,也保存了bb和函数aa的参数对象arguments,为什么会保存这么多呢?因为eval里面的字符串是动态的,没办法分析,谁知道执行时候里面引用了哪个变量,为了保证里面不执行出错,js引擎形成了一个大的闭包作用域和环境,保存了可能用到的所有变量,这就导致了内存开销比不使用eval的大很多,因此,最好不要再闭包里面搞个eval;

3. Function

3.1 Function简介

Function 对象提供了用于处理函数的方法,直接调用此构造函数可以动态创建函数,这一点与eval类似,但是与eval不同的是,Fucntion构造函数创建的函数只能在全局作用域下运行。 简单来看一个示例:

let a1 = 1;
var a2 = 2;

function aa() {
    let a3 = 3;
    let kk = 3;
    console.log('kk', kk);
    new Function('let a4=4;var a5=5;debugger;console.log("a3",a3);')();
}
aa()

执行后发现 a3 is not defined ,我们调试看看 new Function 发现这确实运行在全局作用域,而a3作为函数aa里面的局部变量自然就访问不到。有童鞋就说了,不是说运行在全局作用域吗,怎么在new Function里面创建的变量 a4,a5都挂在local作用域呢,在这里是函数内局部变量的意思,详细看3.1.4

3.1 对比eval

3.1.1 安全性稍高

由于eval执行时可以访问到从自身作用域一直到全局作用域,这其中涉及的变量较多,如果意外修改了某个局部变量会带来意想不到的bug;而Function创建的函数运行在全局作用域中,可以访问到的变量相对少,风险较低(当然要是强行修改全局变量谁也没办法)

3.1.2 性能较高

我们来看看这样一段代码

       const a=1;
        function logDate() {
           
            //套了n层作用域最后
            {
                {
                    {
                        eval('console.log(a)')
                    }
                }
            }
             
        }
        logDate()

在这里我们在全局作用域定义了一个变量a,然后在执行函数套了n层作用域(代码只体现了3层)然后再输出a的值。那使用Function呢

       const a=1;
        function logDate() {
           
            //套了n层作用域最后
            {
                {
                    {
                        Function('console.log(a)')()
                    }
                }
            }
             
        }
        logDate()

毫不意外,两个代码都成功输出a的值,咋一看运行一模一样,但如果我们仔细想想,在没有eval的代码也就是Function中,这个片段运行在全局作用域,浏览器可以放心的直接输出在全局作用域下a的值而不是来自一个局部变量a。而在eval中,浏览器却不会直接就假设a就是全局变量,被迫以较高的代价来查找调用与a同名的任何局部变量,因此与Function对比运行效率都不高。另外如果只用一次就想着可以复用整个函数的话肯定的是用Function划算,调用一次就成为了函数,解析调用比eval快一大截。

3.1.3 内存开销比eval小

通过 2.3.3我们知道,js引擎不知道eval里面到底是什么东西就把把整个作用域链给保存了,避免执行出错,这也导致了额外的内存开销。而new Function的设定是在全局作用域下运行,因此就无需关系局部作用域的事了,就不用保留,一定程度上减少了内存开销

3.1.4 两者内部创建变量

在这里我们分为 eval,eval间接调用,new Function三个部分来讨论。

  • 首先看单纯的eval,想想控制台输出什么

            function fun() {
            let a1 = 1;
            eval('var a2=6;let a3=3;debugger;');
             console.log('a1,a2,a3',a1,a2,a3);//a3报错
        }
        fun()
        console.log('a2',a2) //a2报错
    

    结果肯定是 a1,a2正常,a3报错。这也很容易解释,var声明的变量只会创建在全局作用域或者函数作用域内,这里eval是eval作用域,属于块级作用域;因此a2和a1都存在fun这个函数作用域中,对于eval内部debugger相当于自己的闭包作用域了,而a3就存在eval自身的作用域中;如果想a2,不影响其他作用域呢,那就加个 "use strict"吧,会乖乖的挂在eval上的。 在这里插入图片描述

  • eval间接调用。将上面代码稍微改造一下

        function fun() {
            let a1 = 1;
            (1, eval)('var a2=6;let a3=3;debugger;');
            console.log('a1,a2,a3', a1, a2, a3);// 1,6,a3报错
        }
        fun();
        console.log('a2,a3',a2,a3);//6,a3报错
           
    

结果也很容易解释通,当间接调用eval后,运行在全局作用域,a2自然创建的为全局变量,而a3还是在eval内部; 在这里插入图片描述

  • new Function调用,再改造一下
        function fun() {
            let a1 = 1;
            new Function('var a2=6;let a3=3;debugger;')();
            console.log('a1,a2,a3', a1, a2, a3);//全报错
        }
        fun();
        console.log('a2,a3',a2,a3);//a2,a3报错
           
    
    new Functioin运行再全局作用域中,而在new Function内部声明的变量,会储存在自身的局部作用域中,在这里是本地(local)作用域,也就是自身的函数作用域,不会影响别人,真正实现了作用域隔离。 在这里插入图片描述 如果new Function里面嵌套new Function呢? 在这里我们先定义了一个new Function,然后再内部在讨一个new Function,结果会是怎么样
        function fun() {
           new Function('var a2=6;let a3=3;new Function("var a4=6;let a5=3;debugger;console.log(a2,a3)")()')();
       }
       fun();
    
    在这里插入图片描述 很可惜,直接报错,找不到a2,a3。咋一看不合理呀,内部的new Function按原来思路应该可以访问到外部new Function创建的局部变量来着,如果这样那么就大错特错了。两者都运行在全局作用域,自身创建的变量只会保留在自己的局部作用域中,当我们在内层new Function中尝试去找a3时,会经历一下步骤
    • 1.内层new Function局部作用域--->没有a3
    • 2.全局作用域--->没有a3
    • 3.抛出 ReferenceError: a3 is not defined 要是想让内层的new Function可以访问a3呢,那就加个传参吧~。因此,在这种机制下,两个new Function嵌套形成闭包也没有,因为这两个作用域根本就不会受影响。

4.最后

在实际业务场景中,new Function似乎都可以覆盖eval的场景,(除非你真的需要修改局部变量,如果那些场景不满足,可以在评论区大家一起讨论)而且内存开销也比较少,改用new Function确实是一个不错的选择。最后至于怎么选,交给业务来解决吧