04 | 函数表达式:涉及大量概念,函数表达式到底该怎么学?

你好,我是李兵。

前面几节我们聊了V8中的对象和函数,并介绍了函数为什么会被称为是一等公民,了解这些之后,我们就可以来学习函数表达式了。

函数表达式在JavaScript中非常基础也非常重要,使用函数表达式可以用来实现代码隐藏,还可以实现变量隔离,所以函数表达式被广泛地应用在各个项目中,了解函数表达式的底层工作机制,可以帮助我们更加深刻地理解项目。

但是,学好函数表达式并不容易。因为它涉及到了很多底层概念,比如表达式、语句、函数即对象(在JavaScript中)等,而且函数表达式和函数声明看起来类似,都是定义一个函数,然后再调用该函数,很容易把二者搞混淆了。

实际上,函数表达式和函数声明有着本质上的差异。理解了这种差异,你对函数表达式的理解也就加深了。

函数声明与函数表达式的差异

那么它们具体有什么差异呢?我们先来看一段代码:

foo()
function foo(){
    console.log('foo')
}

在这段代码中,我声明了一个foo函数,然后在foo函数之前调用了foo函数,执行这段代码,我们看到foo函数被正确执行了。(你可能会好奇,代码不是自上而下执行吗,为什么在函数声明之前就可以调用该函数了呢?这个问题我们先留一边,后文中会进行解答。)

再来看另外一段代码:

foo()
var foo = function (){
    console.log('foo')
}

在这段代码中,我定义了一个变量foo,然后将一个函数赋值给了变量foo,同样在源码中,我们也是在foo函数的前面调用foo,执行这段代码,我们发现报错了,提示的错误信息如下所示:

VM130:1 Uncaught TypeError: foo is not a function
    at <anonymous>:1:1

这是告诉我们,变量foo并不是一个函数,所以无法被调用。

同样是在定义的函数之前调用函数,第一段代码就可以正确执行,而第二段代码却报错,这是为什么呢?

其主要原因是这两种定义函数的方式具有不同语义,不同的语义触发了不同的行为。

因为语义不同,所以我们给这两种定义函数的方式使用了不同的名称,第一种称之为函数声明,第二种称之为函数表达式。

下面我们就来分别分析下,函数声明和函数表达式的语义,以及V8是怎么处理函数声明和函数表达式的。

V8是怎么处理函数声明的?

我们先来看函数声明,函数声明定义了一个具有指定参数的函数,其声明语法如下所示:

function name([param,[, param,[..., param]]]) {
   [statements]
}

接下来我们来看看V8是怎么处理函数声明的。

我们知道,V8在执行JavaScript的过程中,会先对其进行编译,然后再执行,比如下面这段代码:

var x = 5
function foo(){
    console.log('Foo')
}

V8执行这段代码的流程大致如下图所示:

在编译阶段,如果解析到函数声明,那么V8会将这个函数声明转换为内存中的函数对象,并将其放到作用域中。同样,如果解析到了某个变量声明,也会将其放到作用域中,但是会将其值设置为undefined,表示该变量还未被使用。

然后在V8执行阶段,如果使用了某个变量,或者调用了某个函数,那么V8便会去作用域查找相关内容。

关于作用域的数据,你也可以使用D8来查看,具体操作方式如下:

  • 将这段代码保存到test.js中;
  • 使用“d8 --print-scopes test.js”命令即可查看作用域的状态。

执行这段指令之后,打印出如下信息:

Global scope:
global { // (0x7fb62281ca48) (0, 50)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0x7fb62281cfe8) local[0]
  // local vars:
  VAR x;  // (0x7fb62281cc98)
  VAR foo;  // (0x7fb62281cf40)


  function foo () { // (0x7fb62281cd50) (22, 50)
    // lazily parsed
    // 2 heap slots
  }
}

上面这段就是V8生成的作用域,我们可以看到,作用域中包含了变量x和foo,变量x的默认值是undefined,变量foo指向了foo函数对象,foo函数对象被V8存放在内存中的堆空间了,这些变量都是在编译阶段被装进作用域中的。

因为在执行之前,这些变量都被提升到作用域中了,所以在执行阶段,V8当然就能获取到所有的定义变量了。我们把这种在编译阶段,将所有的变量提升到作用域的过程称为变量提升

了解了变量提升,我们就能解释,为什么可以在函数声明之前调用该函数了,这是因为声明的函数在编译阶段就被提升到作用域中,在执行阶段,只要是在作用域中存在的变量或者对象,都是可以使用的。

对于变量提升,函数和普通的对象还是存在一些差异的,通过上面的分析我们知道,如果是一个普通变量,变量提升之后的值都是undefined,如果是声明的函数,那么变量提升之后的值则是函数对象,我们可以通过下面的代码来实践下:

console.log(x)
console.log(foo)
var x = 5
function foo(){
}

执行上面这段代码,我们可以看到,普通变量x的值就是undefined,而函数对象foo的值则是完整的对象,那这又是为什么呢?这就是涉及到表达式和语句的区别了。

简单地理解,表达式就是表示值的式子,而语句是操作值的式子。

比如:

x = 5

就是表达式,因为执行这段代码,它会返回一个值。同样,6 === 5 也是一个表达式,因为它会返回False。

而语句则不同了,比如你定义了一个变量:

var x

这就是一个语句,执行该语句时,V8并不会返回任何值给你。

同样,当我声明了一个函数时,这个函数声明也是一个语句,比如下面这段函数声明:

function foo(){
  return 1
}

当执行到这段代码时,V8并没有返回任何的值,它只是解析foo函数,并将函数对象存储到内存中。

了解了表达式和语句的区别,接下来我们继续分析上面的问题。我们知道,在V8执行var x = 5这段代码时,会认为它是两段代码,一段是定义变量的语句,一段是赋值的表达式,如下所示:

var x = undefined
x = 5

首先,在变量提升阶段,V8并不会执行赋值的表达式,该阶段只会分析基础的语句,比如变量的定义,函数的声明。

而这两行代码是在不同的阶段完成的,var x 是在编译阶段完成的,也可以说是在变量提升阶段完成的,而x = 5是表达式,所有的表达式都是在执行阶段完成的。

在变量提升阶段,V8将这些变量存放在作用域时,还会给它们赋一个默认的undefined值,所以在定义一个普通的变量之前,使用该变量,那么该变量的值就是undefined。

现在我们知道,表达式是不会在编译阶段执行的,那么函数声明是表达式还是语句呢?你可以看下面这段函数声明:

function foo(){
    console.log('Foo')
}

执行上面这段代码,它并没有输出任何内容,所以可以肯定,函数声明并不是一个表达式,而是一个语句。V8在变量提升阶段,如果遇到函数声明,那么V8同样会对该函数声明执行变量提升操作。

函数也是一个对象,所以在编译阶段,V8就会将整个函数对象提升到作用域中,并不是给该函数名称赋一个undefined,理解这一点尤为重要。

总的来说,在V8解析JavaScript源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为undefined,如果遇到的是函数声明,那么V8会在内存中为声明生成函数对象,并将该对象提升到作用域中。

V8是怎么处理函数表达式的?

了解了函数声明,我们再来看看函数表达式。我们在一个表达式中使用function来定义一个函数,那么就把该函数称为函数表达式。

比如:

 foo = 1

它是一个表达式,这时候我们把右边的数字1替换成函数定义,那么这就变成了函数表达式,如下所示:

foo = function (){
    console.log('foo')
}

函数表达式与函数声明的最主要区别有以下三点:

  • 函数表达式是在表达式语句中使用function的,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式;
  • 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions);
  • 一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)。

了解了函数表达式,我们就来分析这段代码:

foo()
var foo = function (){
    console.log('foo')
}

当执行这段代码的时候,V8在编译阶段会先查找声明语句,你可以把这段代码拆分为下面两行代码:

var foo = undefined
foo = function (){
    console.log('foo')
}

第一行是声明语句,所以V8在解析阶段,就会在作用域中创建该对象,并将该对象设置为undefined,第二行是函数表达式,在编译阶段,V8并不会处理函数表达式,所以也就不会将该函数表达式提升到作用域中了。

那么在函数表达式之前调用该函数foo,此时的foo只是指向了undefined,所以就相当于调用一个undefined,而undefined只是一个原生对象,并不是函数,所以当然会报错了。

立即调用的函数表达式(IIFE)

现在我们知道了,在编译阶段,V8并不会处理函数表达式,而JavaScript中的立即函数调用表达式正是使用了这个特性来实现了非常广泛的应用,下面我们就来一起看看立即函数调用表达式。

JavaScript中有一个圆括号运算符,圆括号里面可以放一个表达式,比如下面的代码:

(a=3)

括号里面是一个表达式,整个语句也是一个表达式,最终输出3。

如果在小括号里面放上一段函数的定义,如下所示:

(function () {
    //statements
})

因为小括号之间存放的必须是表达式,所以如果在小阔号里面定义一个函数,那么V8就会把这个函数看成是函数表达式,执行时它会返回一个函数对象。

存放在括号里面的函数便是一个函数表达式,它会返回一个函数对象,如果我直接在表达式后面加上调用的括号,这就称为立即调用函数表达式(IIFE),比如下面代码:

(function () {
    //statements
})()

因为函数立即表达式也是一个表达式,所以V8在编译阶段,并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。

在ES6之前,JavaScript中没有私有作用域的概念,如果在多人开发的项目中,你模块中的变量可能覆盖掉别人的变量,所以使用函数立即表达式就可以将我们内部变量封装起来,避免了相互之间的变量污染。

另外,因为函数立即表达式是立即执行的,所以将一个函数立即表达式赋给一个变量时,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。如下所示:

var a = (function () {
    return 1
})()

总结

今天我们主要学习V8是如何处理函数表达式的。函数表达式在实际的项目应用中非常广,不过由于函数声明和函数表达式之间非常类似,非常容易引起人们的误解,所以我们先从通过两段容易让人误解的代码,分析了函数声明和函数表达式之间的区别。函数声明的本质是语句,而函数表达式的本质则是表达式。

函数声明和变量声明类似,V8在编译阶段,都会对其执行变量提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。

不过V8对于提升函数和提升变量的策略是不同的,如果提升了一个变量,那么V8在将变量提升到作用域中时,还会为其设置默认值undefined,如果是函数声明,那么V8会在内存中创建该函数对象,并提升整个函数对象。

函数表达式也是表达式的一种,在编译阶段,V8并不会将表达式中的函数对象提升到全局作用域中,所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用,因此在一些大的开源项目中有广泛的应用。

思考题

留给你一道经典面试题,看看下面这两段代码打印出来的结果是什么?欢迎你在留言区与我分享讨论。

var n = 1;
(function foo(){
    n = 100;
    console.log(n);
}())
console.log(n);
var n = 1;
function foo(){
    n = 100;
    console.log(n);
}
console.log(n);
foo()

感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

精选留言

  • 刘大夫

    2020-03-24 01:26:33

    个人理解:第一题,编译阶段,先在全局作用域内声明变量 n 的值为undefined,然后声明函数表达式;编译阶段结束,开始执行阶段,将 1 赋值给 n,函数表达式“立即执行”,欲将 100 赋值给变量 n,但在当前作用域内没有找到 n 的声明,沿着作用域链向上查找,在全局作用域内找到了变量 n,将 100 赋给它,接着执行立即函数调用表达式中的打印语句,输出 n,为100;接着执行后面的打印语句,此时 n 的值已经由 1 被修改为了 100,因此输出 100;第二题就简单了,第一次打印的时候,函数没有执行,输出的是全局作用域中声明并赋值好的1;然后函数执行,将全局作用域中的 n 修改为 100,因此输出 100。不知道理解的对不对。两道题共同的难点在于不管在函数体内部还是IIFE内部都没有对 n 进行声明,导致需要沿着作用域链向上查找,最终修改了全局作用域中 n 的值;第一题还有迷惑人的地方就是在执行阶段,将 1 赋值给 n 和 IIFE 哪个先执行
  • 白闹

    2020-04-10 00:38:11

    老师,我有一个巨大的疑问,麻烦您有时间的话,一定帮我解答,回答是或不是就行!
    在函数即对象一文,您提到“函数有个隐藏属性是 code 属性,其值表示函数代码,以字符串的形式存储在内存中。”,所以我理解成了,函数本质是储存在栈中的!
    可是在这篇文里,你明确说到函数是被v8储存在堆中里的。
    能解释下,具体是什么回事么?
    因为感觉函数和普通对象还是有些区别的,之前看那篇文章里提到的code属性,以为区别就是函数有一部分是储存在栈中,现在似乎是我理解错了?请帮忙确认一下,谢谢老师!!
    作者回复

    抱歉,最近事情一堆,忙得不可开交,耽误回复问题了!

    不知道我理解你的意思对不对,你的意思是无论什么原生类型都是存放在栈中的,比如某个对象中的一个属性值,如果它是个原生类型,那么原生该值会存放在栈中!

    这个理解是错误的,如果你定义了一个对象,那么在运行时,改对象一定是存放在堆中的,包括对象里面的原生类型的属性值也是存放在堆中的!

    所以当一个V8在编译阶段解析到一个函数声明时,它首先在堆中创建改函数对象,然后为改对象设置各个属性值!

    还有问题欢迎继续提问🤗

    2020-04-10 15:29:32

  • 墨灵

    2020-03-29 19:05:57

    ```
    (var a = 1)
    (a = 1)
    ```
    在浏览器尝试执行了以上两个语句,猜猜结果怎么样,第一个语句是报了个语法错误。
    Uncaught SyntaxError: Unexpected token 'var'
    这就证明了()内只能是表达式,第一句是声明语句,第二句是赋值表达式。function() {} 是个很特殊的东西,没有等号它就是一个函数声明语句,在等号的右边那就变成了函数表达式了。小括号内只能接受表达式,所以它在小括号内也就变成了函数表达式了。
  • 2020-03-24 10:54:18

    在文中一会说 解析阶段,一会说编译阶段,这里这两个概念是一样的吧,解析阶段就是编制阶段,生成 作用域和字节码
    作者回复

    嗯 ,
    大的范围上来讲,我把从源码解析到输出字节码和字节码编译为二进制代码,都称为编译阶段,执行字节码和执行二进制代码称为执行阶段!


    小范围来讲,编译阶段又分为解析,预解析、生成字节码、编译成二进制代码!

    这里一个是大范围的编译,一个是小范围的!

    写的时候也注意到这个问题了,但是没有想到一个更简洁的方式

    2020-03-25 20:28:25

  • Corryenjoy

    2020-04-08 10:43:12

    有一个疑问,以前看过一个篇阮一峰老师的关于loop event的文章。里面有一句话是这样的:“所有任务都在主线程上执行,形成一个执行栈。“

    按照当前文章的理解,所有的程序在执行前引擎都会有一个编译过程,生产作用域和字节码。这样在编译过程中,函数的执行应该会把所有的函数加入一个执行队列中,然后按照先进先出方式对函数进行执行。这样的理解对于在主线程的任务应该是一个执行队列,而不是一个执行栈。

    所以想请老师帮忙解惑一下~
    作者回复


    消息队列里面的存放的是一个个嗷嗷待执行的任务,然后主线程会从消息队列中按照特定的策略:
    while(1){
    取出任务()
    执行任务()
    }
    在执行每个任务的过程中,都会有一个栈结构来管理这个任务的函数调用关系;

    所以说,执行栈又称调用栈,它是针对消息队列中的单个任务的!

    2020-04-09 11:45:34

  • 王楚然

    2020-03-24 14:04:24

    思考题:
    1. 输出100,100。函数表达式立即执行,修改n值为100。
    2.输出1,100。 函数只是被放入作用域,并未执行,因此n值先为1,调用函数后为100。
    作者回复

    没问题

    2020-07-03 21:35:14

  • qinsi

    2020-03-24 13:39:39

    文中提到IIFE的写法是

    (function () {
    //statements
    })()

    而第一题里是

    (function () {
    //statements
    }())

    效果是一样的
    作者回复

    是的

    2020-07-03 21:35:01

  • 一飞同学

    2020-04-09 17:24:31

    从 V8 编译的角度理解变量提升,豁然开朗,比之前的理解清晰多了。
    作者回复

    2020-04-10 15:31:37

  • 潇潇雨歇

    2020-03-24 13:51:33

    课后题:第一个打印为100、100,这里的foo函数会立即执行,执行该函数之后n的值就为100了,这里并没有重新声明一个变量,它会向上查找,最后找到全局变量中的n,即第一行声明的。后面打印的都是变量n的值。
    第二个打印为1、100;foo在打印n后执行,所以第一个为1,这里没有改变n的值;执行foo函数后,n的值为100。
  • 啵啵张💪

    2020-06-18 11:21:52

    V8输出的作用域中,foo变量指向foo函数? 为什么看内存地址不是一个地址呢?这个指向是怎么看出来的?
  • 洋洋

    2020-03-24 10:14:16

    第一题输出: 100 100
    第二题输出: 1 100
    作者回复

    没问题

    2020-07-03 21:27:52

  • Geek_177f82

    2024-08-14 10:23:37

    (function foo(){
    foo = 100;
    console.log(foo);
    }())



    FunctionExpression : 函数 BindingIdentifier ( FormalParameters ) { FunctionBody }
    1. 断言:名称不存在。
    2. 将 name 设置为 BindingIdentifier 的 StringValue。
    3. 设 outerEnv 为正在运行的执行上下文的 LexicalEnvironment。
    4. 设 funcEnv 为 NewDeclarativeEnvironment(outerEnv)。
    5. 执行 !funcEnv.CreateImmutableBinding(name, false)。
    6. 设 privateEnv 为正在运行的执行上下文的 PrivateEnvironment。
    7. 设 sourceText 为与 FunctionExpression 匹配的源文本。
    8. 设闭包为 OrdinaryFunctionCreate(%Function.prototype%, sourceText, FormalParameters, FunctionBody, NON-LEXICAL-THIS, funcEnv, privateEnv)。
    9.

    执行 SetFunctionName(closure, name)。
    10. 执行 MakeConstructor(closure)。
    11. 执行 !funcEnv.InitializeBinding(名称,闭包)。

    12. 退货封口。注意:FunctionExpression 中的 BindingIdentifier 可以从 FunctionExpression 内部引用
    ecma 规范是这样的, 但是这个 函数名 具体存在哪个作用域, 目前看并不是 函数内的词法环境,也不是变量环境。哪存在哪里呢?请老师解惑。谢谢!(之前看过一篇文章说是存在 一个特殊的变量中。那可不可以理解,这个iife 的函数的标识符是个特例,不是我们传统意义的scope)
  • Unterm Rad

    2021-02-07 23:47:57

    本质上是语句和表达式的区别
  • 王玄

    2020-04-14 11:40:10

        /*
        GO {
          n: undefined 1 100
        }
        */
        var n = 1;
        (function foo() {
          n = 100;
          console.log(n); // 100
        }());
        console.log(n); // 100
        /*
        GO {
          n: undefined 1 100, 
          foo: function() {

          },
        }

        AO {
          this: Go,
          arguments: [],
        }
        */
        var n = 1;
        function foo() {
          n = 100;
          console.log(n); // 100
        }
        console.log(n); // 1
        foo();
  • 杨越

    2020-03-24 20:40:48

    老师,函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用。难道函数声明就不能封装变量和函数从而起到变量隔离和代码隐藏的作用了吗???再者,代码隐藏作何解???还望老师百忙中回复这两个问题
    作者回复

    因为当初JavaScript只有函数级作用域,没有块级作用域和空间作用域,所以要实现模块化开发,隐藏内部函数和变量只能使用函数,通常情况下,我们使用函数立即表达式,也就是将一个函数内部需要使用的方法和变量直接暴露给一个全局变量!

    函数和函数立即表达式都能隐藏变量,但是通常模块化开发都是使用函数立即表达式来封装内部方法和变量,并直接返回需要暴露的内容!

    2020-03-25 20:14:20

  • 流乔

    2020-03-24 18:08:51

    老师说的编译一个版本的D8工具啥时候放出来呀?期待。辛苦老师😉😉😉
    作者回复

    下周

    2020-03-25 20:14:26

  • 2020-03-24 16:20:28

    我想问下,文章在讲变量声明以及函数声明提升可以解释一些现象,其他脚本语言很少见到变量和函数提升,JS中它出现的背景是什么?是JS标准就这么规定死的?
    作者回复

    主要是JS当初的定位是一个非常简单的脚本语言,并没有想搞得太复杂了,不支持块级作用域,所以就搞了个变量提升的坑!

    一直祸害至今,虽然目前已经支持块级别了作用域了,但是还要同时支持变量提升这个坑

    2020-03-25 20:16:36

  • ifelse

    2024-06-06 12:50:28

    学习打卡
  • Kris😈 จุ๊บ

    2022-09-19 01:48:34

    --print-scopes 这个找不到
  • Geek_7c57ba

    2022-04-19 18:30:42

    如何解释这段代码呢??
    console.log(a)
    b()
    if(false){
    var a = 2;
    function b(){
    console.log('b...')
    }
    }