Skip to content

箭头函数 vs 普通函数 vs 声明函数

从规范内部槽位看差异

三类函数的本质区别不在语法,而在 ECMAScript 规范定义的内部槽位(Internal Slot)组合。每个函数对象在创建时被分配不同的槽位值,这些槽位决定了函数的运行时行为。

规范中 OrdinaryFunctionCreate 的签名:

text
OrdinaryFunctionCreate(functionPrototype, sourceText, ParameterList, Body,
  thisMode, scope, isGenerator, isAsync)

thisMode 参数是三类函数的核心分叉点:

函数类型thisMode[[Construct]]prototype[[HomeObject]]
函数声明 (function fn(){})non-lexical仅方法语法
函数表达式 (const fn = function(){})non-lexical仅方法语法
箭头函数 (const fn = () => {})lexical
方法简写 ({ fn() {} })non-lexical
Class 方法strict✗(不可 new 调用)
Class 箭头字段 (fn = () => {})lexical(继承自 constructor)

thisMode: lexical 的运行时含义

thisModelexical 时,引擎在解析 this 关键字时走的是 ResolveThisBinding() 的静态路径——直接返回外层词法环境的 [[ThisValue]] 绑定:

js
function outer() {
  // outer 的 [[ThisValue]] 由调用方式决定
  const arrow = () => {
    console.log(this) // this 通过 ResolveThisBinding 静态查找 outer.[[ThisValue]]
  }
  return arrow
}

const fn = outer.call({ name: 'obj' })
fn() // { name: 'obj' } — 不是 undefined,不是 globalThis

对比 thisMode: non-lexical 的函数,this 在每次调用时通过 OrdinaryCallBindThis() 动态确定——取决于调用方式(.call.applyobj.method()new、或裸调用)。


V8 中的函数表示

V8 使用 JSFunction 作为所有函数的运行时表示。在解析阶段,Parser 根据语法形式设置 FunctionKind 枚举位:

text
FunctionKind 枚举(简化):
  kNormalFunction    = 0       ← function fn() {}
  kArrowFunction     = 1 << 0  ← () => {}
  kGeneratorFunction = 1 << 1
  kAsyncFunction     = 1 << 2
  kClassMembers      = 1 << 3
  ...

FunctionKind 标记为 kArrowFunction 时,JSFunction 对象在构造阶段跳过以下字段的分配:

  • prototype 属性不创建([[Construct]] 内部方法不存在)
  • [[HomeObject]] 不分配
  • arguments 对象查找路径走外层词法作用域

SharedFunctionInfo(所有同源函数共享的元数据)也记录了 FunctionKind。在 Turbofan(V8 的优化编译器)中,函数内联决策会检查 FunctionKind 以决定是否可以内联——箭头函数由于无 prototype、无 arguments、无 this 动态绑定,更容易被 Turbofan 内联,理论上调用开销更低(与普通函数差异在微秒级,热路径可测量)。

new 调用的引擎检查

[[Construct]] 内部方法在 V8 中映射到 JSBuiltinConstructStub。当对箭头函数使用 new 时:

text
new arrowFn()
  → 检查 JSFunction 的 [[Construct]] 是否存在
  → 不存在 → 抛出 TypeError: arrowFn is not a constructor

这不是语法级别的检查,而是运行时检查——引擎在函数对象构造时根据 FunctionKind 决定是否分配 [[Construct]] 槽位。这也是为什么 Proxy 配合箭头函数无法绕过 new 限制的原因——Proxy 可以拦截 [[Call]],但 [[Construct]] 的缺失是函数对象创建时决定的,Proxy 无法凭空创建不存在的内部方法。


this 绑定的优先级规则

规范定义的 this 确定顺序(OrdinaryCallBindThis 的决策树):

text
1. 函数是否为箭头函数?([[ThisMode]] === lexical)
   → 是:忽略所有调用上下文,使用定义时捕获的 this → 结束
   → 否:继续

2. 是否通过 new 调用?([[ConstructorKind]] === base)
   → 是:this = 新创建的对象 → 结束
   → 否:继续

3. 是否通过 .call / .apply / .bind 调用?
   → 是:this = 显式绑定的值(非严格模式下原始值被 ToObject 包装)→ 结束
   → 否:继续

4. 是否作为对象方法调用?(obj.fn())
   → 是:this = obj → 结束
   → 否:继续

5. 裸调用:
   → 严格模式:this = undefined
   → 非严格模式:this = globalThis(浏览器中为 window)

箭头函数在步骤 1 直接返回——这是它"不可被 bind/call/apply 修改 this"的规范根因。fn.bind(obj) 创建的新函数对象内部调用 [[Call]] 时走步骤 3,但箭头函数会在步骤 1 提前返回,.bind 的 thisArg 被忽略。

js
const arrow = () => console.log(this)
const boundArrow = arrow.bind({ x: 1 })  // bind 不报错,但 thisArg 无效
boundArrow()                               // 仍然是外层作用域的 this

arguments 对象的差异

箭头函数:词法查找 arguments

箭头函数本身没有 arguments 绑定——规范规定箭头函数在创建时不执行 CreateMappedArgumentsObject。当箭头函数体内引用 arguments 时,标识符解析沿作用域链向上查找:

js
function outer() {
  const arrow = () => {
    console.log(arguments) // 向外层查找 → outer 的 arguments
  }
  arrow()
}
outer(1, 2, 3) // Arguments(3) [1, 2, 3]

Node.js 模块包装器的意外注入

CommonJS 模式下,Node.js 将每个 .js 文件包裹在 IIFE 中:

js
;(function (exports, require, module, __filename, __dirname) {
  // 用户代码在此
})

用户代码中箭头函数的 arguments 引用会向上查找到这个包装器的 arguments——这是 Node.js 特有的行为偏差,不是箭头函数的规范行为。ES Module 模式下(.mjs)不存在此包装器,arguments 未定义。

普通函数 arguments 的非严格模式 live binding

非严格模式下,arguments 与命名参数之间存在活的绑定(live binding):

js
function fn(a) {
  arguments[0] = 99
  console.log(a) // 99 — 非严格模式下双向同步
}
fn(1)

function fnStrict(a) {
  'use strict'
  arguments[0] = 99
  console.log(a) // 1 — 严格模式下完全独立
}
fnStrict(1)

箭头函数不存在 arguments,自然也不存在此行为差异。


常见处理方式:this 丢失场景

方案一:箭头函数替代 .bind

js
class Component {
  constructor() {
    // ❌ .bind 在构造函数中创建新函数,冗余且易忘
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() { /* this 指向实例 */ }
}

// ✅ Class 箭头字段 — 定义时自动绑定
class Component {
  handleClick = () => { /* this 指向实例 */ }
}

Class 箭头字段(Class Fields,ES2022)在实例构造时创建箭头函数,箭头函数的 this 从 constructor 的作用域捕获(constructor 的 this 即实例)。代价是每个实例都会创建一个新的函数对象。

适用:React 类组件的事件处理、任何需要方法保持 this 的场景。

方案二:函数声明/表达式引用提取(解耦 this)

js
function Counter(initial) {
  this.count = initial
}

// ❌ 方法提取后 this 丢失
const c = new Counter(0)
const increment = c.increment // 丢失 this
increment()                     // this = undefined(严格模式)

// ✅ 函数不依赖 this,通过参数传递状态
function increment(state) {
  return { ...state, count: state.count + 1 }
}

将函数设计为不依赖 this 的纯函数,通过参数传递上下文。根本上消除 this 绑定问题。

适用:函数式编程风格的代码、Redux reducer、状态转换函数。

方案三:箭头函数用于回调(天然 this 安全)

js
// ❌ 普通函数作为回调 — this 取决于调用方
setTimeout(function () {
  this.doSomething() // this 不是外层实例
}, 1000)

// ✅ 箭头函数 — this 词法绑定
setTimeout(() => {
  this.doSomething() // this 是外层实例
}, 1000)

异步回调、事件监听器的回调中,箭头函数保证 this 与外层一致。

适用:setTimeout、事件监听、Promise.then 中的回调。

方案四:.bind 预绑定(需要复用函数引用时)

js
class Component {
  constructor() {
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    console.log(this)
  }
}

与方案一等效,但使用原型方法 + 构造函数内 bind。优势:方法定义在原型上(非每个实例独立),实例上绑定引用指向原型方法。适用于需要事件解绑的场景——.bind 返回的函数引用稳定,可直接用于 removeEventListener

方案五:使用函数声明(需要函数提升时)

js
// ✅ 函数声明 — 整体提升,先调用后定义也可
calculate(1, 2) // 可正常执行

function calculate(a, b) {
  return a + b + helper(a)

  function helper(x) { // 嵌套函数声明同样提升
    return x * 2
  }
}

// ❌ const + 箭头/表达式 — 声明前访问抛出 ReferenceError
calculate(1, 2) // ReferenceError
const calculate = (a, b) => a + b

函数声明适用于递归、互相调用的函数组、以及需要在函数体内定义辅助函数的场景。代价是变量提升带来的可读性下降——实际执行顺序与源码书写顺序不一致。

适用:递归算法、工具函数库、需要在文件顶部暴露 API 但实现放在末尾的场景。

方案六:使用箭头函数简化 pipeline

js
// ❌ 普通函数 — 需要显式 return {}
data
  .map(function (x) { return x * 2 })
  .filter(function (x) { return x > 10 })

// ✅ 箭头函数 — 单表达式自动 return
data
  .map(x => x * 2)
  .filter(x => x > 10)

// ⚠️ 注意:返回对象字面量需加括号
data.map(x => ({ value: x })) // 不带括号的 { } 会被解析为函数体

箭头函数的简洁语法在链式调用中减少视觉噪音。但不应过度追求简洁——单行箭头函数体超过 80 字符时应考虑拆分或使用普通函数体。

函数类型选择决策树

text
需要 this 绑定调用方吗?
  ├── 是 → 需要 new 构造?
  │         ├── 是 → 函数声明 / 函数表达式(有 prototype)
  │         └── 否 → 需要 arguments?
  │                   ├── 是 → 函数声明 / 函数表达式
  │                   └── 否 → 需要函数提升?
  │                             ├── 是 → 函数声明
  │                             └── 否 → 箭头函数 / 函数表达式
  └── 否(不需要 this)→ 箭头函数(默认首选)

new.target 与元编程场景

new.target(ES6)用于检测函数是否通过 new 调用。箭头函数因缺少 [[Construct]] 而无法被 new 调用,自然无 new.target

js
function Foo() {
  console.log(new.target) // Foo(通过 new 调用) 或 undefined(裸调用)
}

const Bar = () => {
  console.log(new.target) // SyntaxError: new.target not allowed in arrow function
}

普通函数的 new.target[[Construct]] 调用路径中被设置,在 [[Call]] 路径中为 undefinedReflect.construct(fn, args, Target) 可覆盖 new.target 指向 Target


生产环境中的调试

this 丢失排查

Chrome DevTools → Sources → 断点停留在可疑代码行 → Scope 面板:

  • Local → this:直接显示当前上下文中的 this
  • this 显示为 undefined(严格模式)或 Window(非严格模式)而非预期的类实例 → this 绑定问题

Class 箭头字段的内存影响

js
class ListView {
  items = []
  handleClick = (e) => { /* */ }  // 每个 ListView 实例一份 copy
  handleHover = (e) => { /* */ }  // 每个 ListView 实例一份 copy
}

创建 10000 个 ListView 实例 → 20000 个单独的函数对象。原型方法的版本只需 2 个共享函数:

js
class ListView {
  items = []
  handleClick(e) { /* */ }   // ListView.prototype.handleClick
  handleHover(e) { /* */ }   // ListView.prototype.handleHover
}

Chrome DevTools → Memory → Heap Snapshot → 按 Constructor 分组查看闭包数量:若发现大量同名箭头函数占据 heap,说明存在 Class 箭头字段的高频实例化。

权衡:原型方法方案需配合 .bind::(bind operator proposal)来保证 this,增加了调用方的复杂度。选择取决于实例数量和调用频率——实例数 < 1000 时箭头字段的内存开销可忽略不计,> 10k 时需考虑原型方法。

箭头函数不能用作 Generator

箭头函数不支持 function* 语法,因为 yield 需要 [[GeneratorKind]] 槽位,而箭头函数在解析阶段就不允许 function* token 与 => 共存。需要 Generator 时使用普通函数声明/表达式。


Class 方法的 [[HomeObject]]

方法简写语法创建的普通函数具有 [[HomeObject]] 槽位,指向其定义所在的对象:

js
const base = {
  name: 'base',
  greet() {
    // [[HomeObject]] = base
    return `Hello from ${super.name}` // super 通过 [[HomeObject]] 解析
  }
}

super 关键字的解析依赖 [[HomeObject]]。箭头函数没有此槽位,因而在方法简写语法中通过箭头函数引用 super 的行为取决于外层的 [[HomeObject]]

js
const obj = {
  greet() {
    // [[HomeObject]] = obj
    const arrow = () => {
      console.log(super.name) // 通过外层 greet 的 [[HomeObject]] → obj.[[Prototype]]
    }
    arrow()
  }
}

这是箭头函数在 Class 方法中能正确使用 super 的原因——super 的解析沿词法作用域找到了外层的 [[HomeObject]]