第 21 章

函数对象内部结构:[[Call]] vs [[Construct]]、arguments、bind、尾调用

JavaScript 中每一个函数都是一个对象,但并非每一个函数对象都拥有相同的内部能力——箭头函数缺少 [[Construct]],绑定函数拥有额外的内部槽,这些差异不是语法层面的约定,而是规范在对象结构上的硬性区别。

🔹 Level 1 · 你需要知道的

函数的三个基本属性

函数对象暴露三个可观察的属性:lengthnameprototype

属性 含义 特殊情况
length 形参数量(不计默认参数和剩余参数) function f(a, b=1, ...c){}f.length === 1
name 函数名称字符串 匿名函数赋值时推断:const fn = () => {}fn.name === 'fn'
prototype 作为构造函数时,新对象的 [[Prototype]] 箭头函数没有此属性
function greet(name, greeting = 'Hello', ...extras) {
  return `${greeting}, ${name}!`
}

console.log(greet.length)  // 1,只计 name 这一个必填参数
console.log(greet.name)    // 'greet'

const arrow = (x, y) => x + y
console.log(arrow.length)     // 2
console.log(arrow.prototype)  // undefined,箭头函数没有 prototype

箭头函数的三个限制

箭头函数不是普通函数的简写,它在设计上有三处硬性限制:

  1. 没有 prototype 属性 → 不能作为构造函数(new arrow()TypeError
  2. 没有 arguments 对象 → 只能用剩余参数 ...args
  3. this 是词法绑定的 → 来自定义时的外层作用域,不受 call/apply/bind 影响
const Counter = (start) => {
  this.count = start  // 这里的 this 是词法 this,不是新创建的对象
}

// new Counter(0)  // TypeError: Counter is not a constructor

// 正确做法:
function Counter(start) {
  this.count = start
}
const c = new Counter(0)
console.log(c.count)  // 0

bind 的作用

Function.prototype.bind(thisArg, ...partialArgs) 返回一个新函数,永久固定 this 和部分前置参数,不可再次被 call/apply 覆盖。

function multiply(a, b) {
  return a * b
}

const double = multiply.bind(null, 2)  // 固定第一个参数为 2
console.log(double(5))   // 10
console.log(double(10))  // 20

// bind 后的 length 减少
console.log(multiply.length)  // 2
console.log(double.length)    // 1(减去已绑定的参数数量)

arguments 对象(非严格模式)

非箭头函数在非严格模式下拥有 arguments 对象,它包含所有实际传入的参数。

function sum() {
  let total = 0
  for (let i = 0; i < arguments.length; i++) {
    total += arguments[i]
  }
  return total
}

console.log(sum(1, 2, 3, 4))  // 10,接受任意数量参数

现代写法应优先使用剩余参数,arguments 对象是历史遗留产物。


🔸 Level 2 · 它是怎么运行的

函数对象的内部槽完整列表

ECMAScript 规范将函数对象的内部能力编码为内部槽。下表列出 ECMAScript 函数对象(区别于内置函数、绑定函数等)的关键内部槽:

┌─────────────────────────────────────────────────────────────────┐
│              ECMAScript Function Object 内部槽                   │
├──────────────────────┬──────────────────────────────────────────┤
│ 内部槽               │ 说明                                      │
├──────────────────────┼──────────────────────────────────────────┤
│ [[Environment]]      │ 创建时捕获的词法环境(闭包的来源)          │
│ [[PrivateEnvironment]]│ 私有名称环境(class 私有字段用)           │
│ [[FormalParameters]] │ 形参列表(AST 节点)                       │
│ [[ECMAScriptCode]]   │ 函数体代码(AST 节点)                     │
│ [[Realm]]            │ 函数所属的 Realm                           │
│ [[ScriptOrModule]]   │ 所属脚本或模块记录                         │
│ [[ThisMode]]         │ lexical / strict / global                  │
│ [[Strict]]           │ 是否严格模式                               │
│ [[HomeObject]]       │ super 指向的对象(方法简写才有)            │
│ [[Fields]]           │ 类字段定义列表                             │
│ [[PrivateMethods]]   │ 私有方法列表                               │
│ [[ClassFieldInitializer]]│ 字段初始化器标记                       │
│ [[ConstructorKind]]  │ base / derived(子类构造函数是 derived)    │
└──────────────────────┴──────────────────────────────────────────┘

所有可调用对象都必须实现 [[Call]] 内部方法;只有构造函数同时实现 [[Construct]]

函数类型与内部方法的对应关系:

  普通函数 function f() {}
  ├── [[Call]]      ✓
  └── [[Construct]] ✓

  箭头函数 () => {}
  ├── [[Call]]      ✓
  └── [[Construct]] ✗ (没有)

  方法简写 { m() {} }
  ├── [[Call]]      ✓
  └── [[Construct]] ✗ (没有)

  class 构造函数
  ├── [[Call]]      ✗ (不能直接调用,报 TypeError)
  └── [[Construct]] ✓

  绑定函数 f.bind(...)
  ├── [[Call]]      ✓
  └── [[Construct]] ✓(若原函数有 [[Construct]])

[[Call]] 的执行步骤

F.[[Call]](thisArgument, argumentsList) 是普通函数调用(f())触发的内部方法。规范 10.2.1.1 给出完整算法,简化后为:

1. 令 callerContext = 运行中的执行上下文
2. 创建新的执行上下文 calleeContext
3. 令 localEnv = NewFunctionEnvironment(F, thisArgument)
   - 如果 F.[[ThisMode]] === 'lexical'(箭头函数):
       不绑定 this,this 来自外层
   - 如果 F.[[ThisMode]] === 'strict':
       this = thisArgument(可以是任意值)
   - 否则(global 模式):
       this = ToObject(thisArgument)(undefined/null → 全局对象)
4. 将 argumentsList 绑定到形参
5. 将 calleeContext 推入执行上下文栈
6. 执行 F.[[ECMAScriptCode]]
7. 弹出 calleeContext
8. 返回执行结果

[[Construct]] 的执行步骤

F.[[Construct]](argumentsList, newTarget)new F() 触发的内部方法。newTarget 是被 new 直接调用的那个函数(在继承中和 F 可能不同)。

[[Construct]] 算法(base 构造函数):

1. 令 thisArgument = OrdinaryCreateFromConstructor(newTarget, '%Object.prototype%')
   即:新建对象,其 [[Prototype]] = newTarget.prototype
   (注意是 newTarget.prototype,不一定是 F.prototype)

2. 执行 F.[[Call]](thisArgument, argumentsList)

3. 若函数体 return 了一个对象:使用该返回值
   否则:使用 thisArgument

返回值规则图示:

  new F()
     │
     ├── 函数体 return { ... }(对象)
     │       └── 使用返回的对象
     │
     ├── 函数体 return 42(原始值)
     │       └── 忽略,使用 thisArgument(新创建的对象)
     │
     └── 函数体没有 return / return undefined
             └── 使用 thisArgument
function Factory() {
  this.type = 'default'
  return { type: 'override' }  // 返回对象,new 的结果是这个对象
}
console.log(new Factory())  // { type: 'override' }

function Factory2() {
  this.type = 'default'
  return 42  // 返回原始值,忽略,new 的结果是 this
}
console.log(new Factory2())  // Factory2 { type: 'default' }

绑定函数(BoundFunction)的内部结构

f.bind(thisArg, ...args) 创建的绑定函数不是普通 ECMAScript 函数对象,它是 BoundFunction Exotic Object,有自己的内部槽:

BoundFunction 对象结构:

┌──────────────────────────────────────┐
│         BoundFunctionObject          │
├──────────────────────────────────────┤
│ [[BoundTargetFunction]]  → 原函数 F   │
│ [[BoundThis]]            → thisArg   │
│ [[BoundArguments]]       → [arg1...] │
├──────────────────────────────────────┤
│ 没有自己的 [[Environment]]            │
│ 没有自己的 [[ECMAScriptCode]]         │
│ 调用时委托给 [[BoundTargetFunction]]   │
└──────────────────────────────────────┘

绑定函数的 [[Call]] 实现:

BoundFunction.[[Call]](thisArgument, argumentsList):
1. 取出 F = [[BoundTargetFunction]]
2. 取出 boundThis = [[BoundThis]]
3. 取出 boundArgs = [[BoundArguments]]
4. 组合:args = boundArgs + argumentsList
5. 调用 F.[[Call]](boundThis, args)
   注意:thisArgument 被丢弃,始终使用 boundThis

[[ThisMode]] 的三种取值

函数对象创建时,[[ThisMode]] 根据语法形式确定,之后不可更改:

[[ThisMode]] 对应语法 this 绑定行为
lexical 箭头函数 () => {} 来自词法外层,不可被覆盖
strict 严格模式函数 'use strict'; function f(){} this 就是传入的 thisArgument,可以是 undefined
global 非严格普通函数 function f(){} thisundefined/null 时,替换为全局对象
'use strict'
function strictFn() {
  return this
}
console.log(strictFn.call(undefined))  // undefined(strict 模式不替换)
console.log(strictFn.call(42))         // 42(strict 模式保留原始值)

function sloppyFn() {
  return this
}
console.log(sloppyFn.call(undefined))  // globalThis(global 模式替换为全局对象)
console.log(sloppyFn.call(42))         // Number {42}(原始值被 ToObject 包装)

arguments 对象的参数同步机制

非严格模式下,arguments 对象和命名形参之间存在一个"活性映射"(parameter map)。规范称之为 Arguments Exotic Object。

非严格模式 arguments 与形参的关系:

function f(a, b) {
  // 内部存在一个参数映射:
  // arguments[0] ↔ a(互相镜像)
  // arguments[1] ↔ b(互相镜像)
}

修改任意一方,另一方也随之改变:
  f(1, 2):
    a = 1, b = 2, arguments[0] = 1, arguments[1] = 2
    
  执行 arguments[0] = 99:
    → a 变成 99(参数映射同步)
    
  执行 a = 777:
    → arguments[0] 变成 777(参数映射同步)
function f(a) {
  arguments[0] = 99
  return a          // 返回 99,不是 1
}
console.log(f(1))   // 99

// 严格模式下不存在这个同步
function g(a) {
  'use strict'
  arguments[0] = 99
  return a          // 仍然返回 1
}
console.log(g(1))   // 1

// 有默认参数时,即使非严格模式也不同步
function h(a = 0) {
  arguments[0] = 99
  return a          // 仍然返回 1(有默认参数时 arguments 是简单副本)
}
console.log(h(1))   // 1

尾调用优化(TCO)

严格模式下,若某次函数调用是尾位置(Tail Position)调用,规范允许引擎复用当前栈帧而不是新建。这让纯函数式的递归写法不再有栈溢出风险。

尾位置的判断规则(规范 14.6):

普通递归(无 TCO)的栈增长:

fact(5)
  └── fact(4)
        └── fact(3)
              └── fact(2)
                    └── fact(1)
                          └── fact(0) → 1
每一层都需要保留栈帧,等待子调用返回

尾递归(有 TCO 时)的栈:

fact(5, 1)
  ↓ 复用当前栈帧
fact(4, 5)
  ↓ 复用
fact(3, 20)
  ↓ 复用
fact(2, 60)
  ↓ 复用
fact(1, 120)
  ↓ 复用
fact(0, 120) → 120
始终只有一个栈帧
// 非尾递归版本(n 大时 stack overflow)
function factorial(n) {
  if (n <= 1) return 1
  return n * factorial(n - 1)  // 乘法在尾位置,factorial 调用不在尾位置
}

// 尾递归版本(规范允许 TCO,但 V8 没实现)
'use strict'
function factorial(n, acc = 1) {
  if (n <= 1) return acc
  return factorial(n - 1, n * acc)  // 这才是尾调用
}

TCO 的实现现状:Safari(JavaScriptCore)在严格模式下实现了 TCO;V8(Chrome/Node.js)明确拒绝实现,理由是会破坏调用栈信息,使调试困难。因此 TCO 目前只是规范要求,不是可以依赖的跨引擎特性。


🔺 Level 3 · 规范怎么定义的

10.2 ECMAScript Function Objects

规范第 10.2 节定义了 ECMAScript 函数对象的创建过程。OrdinaryFunctionCreate 是核心操作:

OrdinaryFunctionCreate(functionPrototype, sourceText, ParameterList, Body, thisMode, env, privateEnv):

1. 令 F = OrdinaryObjectCreate(functionPrototype)
   // 以 functionPrototype 为原型创建普通对象
   
2. 设置内部槽:
   F.[[Call]] = %ECMAScriptFunction%.[[Call]] 的实现
   F.[[FormalParameters]] = ParameterList
   F.[[ECMAScriptCode]] = Body
   F.[[Realm]] = 当前 Realm
   F.[[Environment]] = env
   F.[[PrivateEnvironment]] = privateEnv
   F.[[ScriptOrModule]] = 当前脚本或模块
   F.[[Strict]] = strict(由 Body 是否包含 'use strict' 决定)
   
3. 若 thisMode === 'lexical-this':
     F.[[ThisMode]] = 'lexical'
   否则若 F.[[Strict]]:
     F.[[ThisMode]] = 'strict'
   否则:
     F.[[ThisMode]] = 'global'
     
4. F.[[IsClassConstructor]] = false
5. F.[[Fields]] = []
6. F.[[PrivateMethods]] = []
7. F.[[ClassFieldInitializer]] = false
8. F.[[ConstructorKind]] = 'base'
9. 返回 F

此时函数对象没有 [[Construct]]。只有当函数声明/表达式被处理为非箭头函数时,规范才会调用 MakeConstructor(F) 为其添加 [[Construct]] 能力:

MakeConstructor(F):
1. 令 prototype = OrdinaryObjectCreate(%Object.prototype%)
2. 定义 prototype.constructor = F(不可枚举,可写,可配置)
3. 定义 F.prototype = prototype(不可枚举,可写,不可配置)
4. 设置 F.[[Construct]] 为 OrdinaryConstruct 的实现

10.2.1.1 [[Call]] 算法(完整版)

F.[[Call]](thisArgument, argumentsList):

1. 令 callerContext = 运行中的执行上下文
2. 令 calleeContext = PrepareForOrdinaryCall(F, undefined)
   // PrepareForOrdinaryCall 做了:
   //   a. 创建新的执行上下文
   //   b. 创建新的 FunctionEnvironment
   //   c. 将其推入执行上下文栈
   
3. 断言:calleeContext 现在是运行中的执行上下文

4. OrdinaryCallBindThis(F, calleeContext, thisArgument)
   // OrdinaryCallBindThis:
   //   若 F.[[ThisMode]] === 'lexical':返回(不绑定)
   //   若 F.[[ThisMode]] === 'strict':envRec.BindThisValue(thisArgument)
   //   否则:
   //     若 thisArgument 是 undefined 或 null:
   //       thisValue = F.[[Realm]].[[GlobalEnv]].[[GlobalThisValue]]
   //     否则:
   //       thisValue = ToObject(thisArgument)
   //     envRec.BindThisValue(thisValue)
   
5. 令 result = OrdinaryCallEvaluateBody(F, argumentsList)
   // 执行函数体,处理形参绑定

6. 从执行上下文栈移除 calleeContext

7. 若 callerContext 当前不在栈顶(TCO 情形),则恢复它

8. 若 result.[[Type]] 是 return:返回 result.[[Value]]
   否则:返回 undefined

10.2.1.2 [[Construct]] 算法(完整版)

F.[[Construct]](argumentsList, newTarget):

1. 令 kind = F.[[ConstructorKind]]

2. 若 kind === 'base':
     a. 令 thisArgument = OrdinaryCreateFromConstructor(newTarget, '%Object.prototype%')
        // thisArgument.[[Prototype]] = newTarget.prototype
        //(若 newTarget.prototype 不是对象,则用 %Object.prototype%)

3. 令 calleeContext = PrepareForOrdinaryCall(F, newTarget)

4. 若 kind === 'base':
     OrdinaryCallBindThis(F, calleeContext, thisArgument)

5. 令 result = OrdinaryCallEvaluateBody(F, argumentsList)

6. 从执行上下文栈移除 calleeContext

7. 若 result.[[Type]] 是 return:
     若 Type(result.[[Value]]) 是 Object:返回 result.[[Value]]
     若 kind === 'base':返回 thisArgument
     若 result.[[Value]] 不是 undefined:抛出 TypeError
     // derived 构造函数(子类)如果显式 return 非 undefined 原始值会报错

8. 若 result.[[Type]] 不是 normal:抛出 result 的错误

9. 若 kind === 'base':返回 thisArgument
   // 未显式返回对象 → 返回 this

10. 令 thisBinding = calleeContext 的 envRec.GetThisBinding()
    若 thisBinding 是 undefined:抛出 ReferenceError
    // derived 构造函数没调用 super() → this 未初始化
    返回 thisBinding

14.6 尾位置调用规范定义

规范 14.6.1 定义了哪些语法位置是尾位置(Tail Position)。以下是完整的位置列表(节选关键部分):

IsInTailPosition(call) 为 true 当且仅当:

对于 FunctionBody:
  - 严格代码
  - call 在 FunctionBody 的 return 语句的 Expression 中
  
对于 ConciseBody(箭头函数简写体):
  - 严格代码
  - call 就是 ConciseBody 的 AssignmentExpression

对于 StatementListItem 列表:
  - call 在列表最后一项的尾位置

对于 ReturnStatement:`return Expression`
  - call 在 Expression 的尾位置

对于条件表达式 `a ? b : c`:
  - call 在 b 的尾位置,或在 c 的尾位置

对于逻辑表达式 `a || b`、`a && b`、`a ?? b`:
  - call 在 b 的尾位置(右操作数)

以下情况不是尾位置:
  - `return f() + 1`(f() 之后还有加法)
  - `return f().toString()`(f() 之后还有方法调用)
  - `return [f()]`(f() 在数组字面量里)
  - `return f(), g()`(逗号表达式只有最后一项在尾位置)

规范 10.2.1.1 在 [[Call]] 的第 8 步中通过 PrepareForTailCall() 实现 TCO:丢弃当前栈帧并在同一位置重用,使递归调用不增长调用栈。


💎 Level 4 · 边界与陷阱

陷阱 1:new (()=>{})() 报 TypeError

箭头函数没有 [[Construct]] 内部方法。new 运算符在执行前会检查目标是否有 [[Construct]],没有则立即抛出 TypeError,不会尝试执行函数体。

const Arrow = () => {}
new Arrow()
// TypeError: Arrow is not a constructor

// 同样报错的还有:
// new (function.bind(null)())  当 bind 的原函数是箭头函数时
// new ({method() {}}.method)   方法简写也没有 [[Construct]]

class Cls {}
const BoundCls = Cls.bind(null)
new BoundCls()  // 正常,Cls 有 [[Construct]],绑定函数会委托给它

根本原因:规范定义 new expr(args) 调用 GetValue(expr).[[Construct]](args, expr),若对象没有 [[Construct]] 方法则抛出 TypeError(规范 13.3.5.1 步骤 7)。

陷阱 2:length 的四种计算规则

Function.prototype.length 的值不是"所有参数的数量",而是满足特定条件的参数数量:

// 规则:length = 第一个默认参数或剩余参数之前的参数数量

function f1(a, b, c) {}           // 3
function f2(a, b = 1, c) {}       // 1(b 是默认参数,b 及之后不计)
function f3(a, ...rest) {}        // 1(rest 不计)
function f4(a, b = 1, ...rest) {} // 1
function f5({ a, b }, c) {}       // 2(解构参数算一个)

console.log(f1.length, f2.length, f3.length, f4.length, f5.length)
// 3 1 1 1 2

// bind 之后:length = max(0, 原 length - 绑定参数数量)
const f = function(a, b, c) {}  // length = 3
const b1 = f.bind(null)         // length = 3(绑定 this 不算参数)
const b2 = f.bind(null, 1)      // length = 2
const b3 = f.bind(null, 1, 2, 3, 4)  // length = 0(不会是负数)

console.log(b1.length, b2.length, b3.length)
// 3 2 0

这个计算规则来自规范 10.2.10 SetFunctionLength:统计形参列表中第一个默认参数或 rest 参数之前的参数个数。

陷阱 3:arguments 同步的边界条件

非严格模式下 arguments 与形参同步,但有三种情况会破坏这个同步:

// 情况 1:有默认参数时,即使非严格模式,也不同步
function f1(a = 0) {
  arguments[0] = 99
  return a
}
console.log(f1(1))  // 1,不是 99

// 情况 2:有剩余参数时,也不同步
function f2(...args) {
  args[0] = 99  // 注意:这不是 arguments,而是剩余参数数组
  return args[0]
}
console.log(f2(1))  // 99(但这是数组,不是 arguments 对象)

// 情况 3:未传入的参数,不建立同步
function f3(a, b) {
  arguments[1] = 99
  return b  // b 是 undefined,不是 99
}
console.log(f3(1))  // undefined,未传入的参数不建立映射

// 情况 4:严格模式,完全不同步
function f4(a) {
  'use strict'
  arguments[0] = 99
  return a  // 仍然是 1
}
console.log(f4(1))  // 1

规范对这个"神奇同步"的实现是 Arguments Exotic Object(规范 10.4.4):对象内部有一个 [[ParameterMap]] 属性,存储 arguments 索引到形参 Environment Record 绑定的映射。只有被实际传入的命名参数才会建立映射;有默认参数或剩余参数时(规范 10.2.11 步骤 20),整个 arguments 对象退化为普通对象(不建立映射)。

陷阱 4:bind 之后无法再改变 this

bind 创建的绑定函数的 [[BoundThis]] 是硬编码的,后续任何 call/apply/bind 都无法改变:

function greet() {
  return `Hello, ${this.name}`
}

const alice = { name: 'Alice' }
const bob = { name: 'Bob' }

const greetAlice = greet.bind(alice)
console.log(greetAlice())               // 'Hello, Alice'
console.log(greetAlice.call(bob))       // 'Hello, Alice'(call 无效)
console.log(greetAlice.apply(bob, []))  // 'Hello, Alice'(apply 无效)

const greetAliceToo = greetAlice.bind(bob)  // 再次 bind 也无效
console.log(greetAliceToo())            // 'Hello, Alice'(仍然是 Alice)

但有一个例外:当绑定函数被 new 调用时,[[BoundThis]] 会被忽略(规范 10.3.2 BoundFunctionExoticObject.[[Construct]] 步骤 6:对于 new 调用,newTarget 被传递给原函数的 [[Construct]]this 由原函数的 [[Construct]] 创建):

function Point(x, y) {
  this.x = x
  this.y = y
}

const OriginPoint = Point.bind({ name: 'ignored' }, 0)
const p = new OriginPoint(5)
console.log(p)  // Point { x: 0, y: 5 },不是 { name: 'ignored', x: 0, y: 5 }
console.log(p instanceof Point)  // true

陷阱 5:尾调用的常见误判

以下写法看似尾调用,实际不是:

'use strict'

// 错误 1:调用后还有操作
function a(n) {
  return n > 0 ? a(n - 1) + 1 : 0  // +1 不是尾位置
}

// 错误 2:包在数组/对象字面量里
function b(n) {
  return [b(n - 1)]  // 不是尾位置
}

// 错误 3:作为参数传给另一个函数
function c(n) {
  return console.log(c(n - 1))  // c(n-1) 是 console.log 的参数,不是尾位置
}

// 正确的尾调用
function d(n, acc = 0) {
  if (n === 0) return acc
  return d(n - 1, acc + 1)  // 这才是尾调用
}

本章小结

  1. 所有函数都有 [[Call]],但只有普通函数和类构造函数有 [[Construct]];箭头函数、方法简写没有 [[Construct]],不能用 new 调用。
  2. [[Construct]] 执行时首先创建新对象([[Prototype]] 来自 newTarget.prototype),执行函数体后若返回对象则用返回值,否则用 this
  3. bind 生成的绑定函数拥有独立的 [[BoundTargetFunction]]/[[BoundThis]]/[[BoundArguments]] 内部槽,this 无法被后续 call/apply/bind 覆盖(除非通过 new 调用)。
  4. Function.prototype.length 计算规则:第一个默认参数或剩余参数之前的参数数量;bind 后 length 减少绑定参数数量(最小为 0)。
  5. 尾调用优化(TCO)在规范第 14.6 节有明确定义,严格模式下适用,但 V8 引擎拒绝实现,目前只有 Safari 支持;生产代码中不应依赖 TCO 进行递归优化。
本章评分
4.7  / 5  (8 评分)

💬 留言讨论