Skip to content
函数声明与函数表达式的区别
概述
function foo() {} 和 const foo = function() {} 这两种写法在词法上相似,但它们在运行时的处理路径完全不同。差异的核心不在于语法形式,而在于引擎创建绑定、生成函数对象以及名称可访问范围的时机。理解这套机制,有助于准确预期代码在声明阶段和执行阶段的行为。
基本概念
执行上下文(execution context)的创建包含 DeclarationInstantiation 阶段。该阶段负责处理当前作用域内的所有声明,包括变量、函数声明以及 let、const、class 等。处理后的结果是一系列记录在环境记录(Environment Record)中的绑定。后续执行阶段才会再逐行运行赋值和函数调用。
函数声明在 DeclarationInstantiation 阶段被完整处理:同时建立绑定名称和函数对象,且函数体已经可用。函数表达式则只建立变量绑定(var 提升后初始值为 undefined,let、const 进入暂存死区),函数对象要等到赋值语句真正执行时才会被创建并写入绑定。
工作原理
函数声明的绑定时机
js
foo(); // 正常调用
function foo() {
console.log('function declaration');
}在 DeclarationInstantiation 阶段,foo 已经被绑定到对应的函数对象,因此在逐行执行之前就能成功调用 foo。这也就是通常所说的“函数提升”。
函数表达式的绑定时机
js
bar(); // TypeError: bar is not a function
var bar = function () {
console.log('function expression');
};var bar 在 DeclarationInstantiation 阶段创建绑定,初始值为 undefined。真正的匿名函数对象在赋值语句执行时才被赋给 bar。在此之前访问 bar,得到的是 undefined,尝试以函数形式调用则抛出 TypeError。
对于 const 声明的函数表达式,绑定同样在该阶段创建,但处于暂存死区(Temporal Dead Zone),提前访问会抛出 ReferenceError:
js
console.log(baz); // ReferenceError
const baz = function () {};三种方式在调用前的状态
js
console.log(typeof foo); // 'function' —— 函数声明已完全就绪
console.log(typeof bar); // 'undefined' —— var 提升,但尚未赋值
console.log(typeof baz); // 抛出 ReferenceError —— const 处于 TDZ
function foo() {}
var bar = function () {};
const baz = function () {};函数声明的覆盖规则
同一作用域内出现多个同名函数声明时,后出现的声明会在 DeclarationInstantiation 阶段直接覆盖前面的绑定。整个过程在执行任何语句之前完成。
js
console.log(foo()); // 2
function foo() { return 1; }
function foo() { return 2; }引擎按源码顺序处理函数声明,第二个 foo 直接覆盖第一个,因此即便是写在最前面的 console.log(foo()),结果也是 2。
函数表达式的覆盖则完全取决于赋值顺序:变量绑定统一建立后,代码按执行流程逐行赋值,后赋值的覆盖先赋值的,行为符合直觉。
块级作用域内的函数声明
函数声明出现在块(如 if 块)内时,行为会因为规范(特别是 Annex B 的兼容性补充)而变得复杂。ES6 规范允许块内函数声明,但其提升行为属于“部分提升”:函数名可能提升到外层作用域,而函数对象的赋值只在进入该块时发生。不同引擎的具体实现可能不同,某些非标准实现甚至会将块内函数声明当作全局声明处理。
为保持行为确定性,应避免在块内直接使用函数声明。需要条件性创建函数时,使用函数表达式:
js
let foo;
if (condition) {
foo = function () { /* ... */ };
}命名函数表达式
函数表达式可以带一个名称,该名称只在函数体内部的作用域可见。
js
const fn = function inner() {
return inner(); // inner 在此函数体内有效
};
inner(); // ReferenceError: inner 在外层作用域不可见这种行为由规范中的 FunctionExpressionScope 实现:引擎在执行函数表达式时创建一个额外的函数作用域,将 inner 以 const 绑定的形式引入,且该绑定不可重新赋值。因此 inner 在函数内部是一个稳定的自引用,不随外部变量变化而失效。
这一特性在递归场景中尤其有用:
js
const factorial = function calc(n) {
if (n <= 1) return 1;
return n * calc(n - 1);
};
const copy = factorial;
factorial = null;
copy(5); // 120 —— calc 绑定不受外部 factorial 重赋值的影响箭头函数
箭头函数(() => {})也属于函数表达式,但与 function 关键字定义的普通函数表达式相比,存在几项关键区别:
- 箭头函数没有自己的
this、arguments、super和new.target,这些值全部从定义所在的词法作用域继承。 - 箭头函数不能被用作构造函数,对其使用
new会抛出TypeError。 - 箭头函数没有
prototype属性。 - 箭头函数在语法上无法被命名,它始终是匿名的,其
name属性来源于赋值目标变量名或通过位置推导(name inference)。
在 DeclarationInstantiation 阶段,箭头函数同样只建立变量绑定,函数对象本身在赋值时创建。其 this 绑定在函数创建时即从外围作用域捕获,而不是在调用时决定。
立即调用的函数表达式(IIFE)
以下形式会触发语法错误:
js
function () {}(); // SyntaxError解析器在遇到 function 关键字时默认尝试将其作为函数声明处理,而声明要求必须有标识符。function () {} 缺少标识符,解析失败。
使用括号包裹(Grouping Operator)可以将解析语境从声明切换为表达式:
js
(function () {})();括号内的 function 被作为函数表达式处理,标识符变为可选,并且表达式的结果允许直接跟 () 执行调用。其他创造表达式语境的方式还包括:
js
void function () {}();
!function () {}();
+function () {}();
0, function () {}();但从代码意图的清晰程度看,括号的方式最好。随着 ES 模块和块级作用域(let、const)的普及,IIFE 原有的作用域隔离功能已被模块系统取代,使用频率大幅降低。
调试栈与命名
匿名函数表达式在错误堆栈中通常显示为 <anonymous>,会增加错误定位的成本。部分现代引擎会根据变量名推断函数名(name inference),但这不是语言规范保证的行为,不应依赖。
js
const handler = function clickHandler() {
throw new Error('fail');
};
// 错误栈中显示 'clickHandler',比 'handler' 或 '<anonymous>' 更易定位在多层回调传递的代码中,使用命名函数表达式可以显著提升错误追踪工具对调用来源的识别效率。
注意点
- 函数声明在作用域的 DeclarationInstantiation 阶段完整建立绑定,函数表达式则在执行阶段才创建函数对象。
- 同名函数声明会直接覆盖,且发生在代码执行之前;函数表达式的覆盖仅取决于赋值顺序。
- 避免在块(
if、for、{}等)内部直接使用函数声明,以防止不同引擎间的提升行为差异。 - 命名函数表达式的内部名称绑定为
const,不可被修改,可用作安全的递归自引用。 - IIFE 的典型用例已基本由模块系统取代,除非追求极简隔离或兼容旧运行环境,否则不必刻意使用。
- 错误栈中的
<anonymous>会增加排查难度,非必要时应优先提供有意义的函数名称。
