阅读视图

发现新文章,点击刷新页面。

JavaScript 对象与属性描述符:从原理到实战

背景:为什么要深入理解对象?

在日常开发中,我们经常会遇到这样的困惑:

  • 为什么有些对象属性用 for-in 遍历不出来?
  • 为什么 delete 有时能删除属性,有时却失效?
  • Vue2 的响应式原理到底是怎么"劫持"属性访问的?

这些问题的答案都指向同一个核心概念:属性描述符。它是 JavaScript 对象系统的底层机制,掌握它不仅能让你理解框架源码,还能写出更精准、更可控的代码。

本文将从面向对象的本质出发,逐步深入到属性描述符的细节,并结合实际场景帮你建立完整的知识体系。

你将收获:

  • 理解 JavaScript 面向对象的设计思想
  • 掌握属性描述符的 6 种特性及应用场景
  • 学会用 Object.defineProperty 精准控制对象行为
  • 具备阅读 MDN 文档和框架源码的基础能力

一、面向对象:用代码模拟现实世界

1.1 什么是面向对象?

面向对象编程(OOP)的核心思想是:用包含数据和行为的对象来模拟现实世界的实体

举个例子:

  • 一辆车(Car):有颜色、速度、品牌、价格等属性,有行驶、刹车等方法
  • 一个人(Person):有姓名、年龄、身高等属性,有吃饭、跑步等方法

这种抽象方式让代码结构更清晰,也更贴近人类的思维方式。在 JavaScript 中,面向对象主要体现在两个方面:

  1. 封装:把相关数据和方法组织在一起(函数、模块、对象都是封装)
  2. 继承:通过原型链实现代码复用(这是 JS 的重点,后续会详细讲解)

1.2 JavaScript 中的对象设计

JavaScript 支持多种编程范式,对象被设计成属性的无序集合,类似哈希表:

{
  key: value
}
  • key:标识符名称(字符串或 Symbol)
  • value:任意类型(基本类型、对象、函数等)
  • 如果 value 是函数,我们称之为方法

1.3 创建对象的两种方式

方式一:new Object()(构造函数方式)

var person1 = new Object();
person1.name = "小吴";
person1.age = 18;
person1.greet = function() {
  console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
};

person1.greet();  // 输出: Hello, my name is 小吴 and I am 18 years old.

适用场景:

  • 需要动态添加属性的复杂逻辑
  • 有 Java/C++ 等面向对象语言背景的开发者

历史背景: JavaScript 早期为了蹭 Java 的热度,在命名和语法上刻意模仿,导致很多 Java 开发者习惯用这种方式。

方式二:对象字面量(推荐)

var person2 = {
  name: "小吴",
  age: 18,
  greet: function() {
    console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
  }
};

person2.greet();  // 输出: Hello, my name is 小吴 and I am 18 years old.

优势:

  • 代码简洁,结构清晰
  • 属性和方法内聚性强
  • 性能略优(省略函数调用开销)

二、属性描述符:精准控制对象行为

2.1 为什么需要属性描述符?

通常我们直接定义属性:

var obj = {
  name: "小吴",
  age: 20,
  sex: "男"
};

// 获取属性
console.log(obj.name);  // 小吴

// 修改属性
obj.name = "XiaoWu";
console.log(obj.name);  // XiaoWu

// 删除属性
delete obj.name;
console.log(obj);  // { age: 20, sex: '男' }

但这种方式无法控制:

  • 这个属性能否被 delete 删除?
  • 这个属性能否被 for-in 遍历?
  • 这个属性能否被重新赋值?

属性描述符就是用来解决这些问题的工具。

2.2 Object.defineProperty 基础用法

Object.defineProperty(obj, prop, descriptor)

参数说明:

  • obj:目标对象
  • prop:属性名(字符串或 Symbol)
  • descriptor:属性描述符对象(核心)

返回值: 修改后的原对象(非纯函数)

示例:

var obj = {
  name: "XiaoWu",
  age: 20
};

Object.defineProperty(obj, "height", {
  value: 1.75
});

console.log(obj);  // Node 环境:{ name: 'XiaoWu', age: 20 }

疑问:为什么 height 没显示出来?

图 1:浏览器控制台显示了 height 属性

原因分析:

  • height 默认是不可枚举的(enumerable: false
  • Node.jsconsole.log 使用 util.inspect(),默认只显示可枚举属性(遵循 ECMAScript 标准)
  • 浏览器控制台 为了调试方便,会显示所有属性(包括不可枚举属性)

验证属性确实存在:

console.log(obj.height);  // 1.75(可以访问)

让属性可枚举:

Object.defineProperty(obj, "height", {
  value: 1.75,
  enumerable: true  // 设置为可枚举
});

console.log(obj);  // { name: 'XiaoWu', age: 20, height: 1.75 }

三、属性描述符的两种类型

属性描述符分为两大类,它们不能混用

类型 configurable enumerable value writable get set
数据描述符
存取描述符

记忆口诀: 2 共用 + 2 可选,同时生效最多 4 种

3.1 为什么不能混用?

本质原因: 它们代表了两种完全不同的属性管理方式

  • 数据描述符(静态):属性持有一个具体的值,可以直接读写
  • 存取描述符(动态):属性值通过函数动态计算,每次访问可能不同

如果同时定义,JavaScript 引擎无法判断应该直接操作值还是调用函数,因此规范禁止混用。

类比理解:

  • 数据描述符 = 名词(静态的"数据")
  • 存取描述符 = 动词(动态的"存取"操作)

四、数据描述符详解

4.1 四大特性

[[Configurable]]:可配置性

控制属性是否可以:

  • delete 删除
  • 修改其他描述符特性
  • 转换为存取描述符

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Enumerable]]:可枚举性

控制属性是否可以:

  • for-in 遍历
  • Object.keys() 返回

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Writable]]:可写性

控制属性值是否可以被修改。

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Value]]:属性值

属性的实际值。

默认值: undefined

4.2 实战案例

var obj = {
  name: "XiaoWu",
  age: 18
};

// 定义一个受控属性
Object.defineProperty(obj, "address", {
  value: "福建省",
  configurable: false,  // 不可删除、不可重新配置
  enumerable: true,     // 可枚举
  writable: false       // 不可修改
});

// 测试 configurable
delete obj.name;
console.log(obj);  // { age: 18, address: '福建省' }(name 被删除)

delete obj.address;
console.log(obj.address);  // 福建省(删除失败)

// 测试 enumerable
console.log(Object.keys(obj));  // [ 'age', 'address' ]

for (var key in obj) {
  console.log(key);  // age, address
}

// 测试 writable
obj.address = "上海市";
console.log(obj.address);  // 福建省(修改失败)

关键点:

  • 直接定义的属性(nameage)默认所有特性都是 true
  • 通过描述符定义的属性(address)默认所有特性都是 false

五、存取描述符详解

5.1 四大特性

  • [[Configurable]]:同数据描述符
  • [[Enumerable]]:同数据描述符
  • [[Get]]:获取属性时执行的函数,默认 undefined
  • [[Set]]:设置属性时执行的函数,默认 undefined

5.2 应用场景

场景一:隐藏私有属性

var obj = {
  name: "小吴",
  age: 18,
  _address: "泉州市"  // _ 开头表示私有属性(约定俗成)
};

Object.defineProperty(obj, "address", {
  enumerable: true,
  configurable: true,
  get: function() {
    return this._address;  // 通过 address 访问 _address
  },
  set: function(value) {
    this._address = value;
  }
});

console.log(obj.address);  // 泉州市
obj.address = "厦门市";
console.log(obj.address);  // 厦门市

注意: ES6 后可以用 # 定义真正的私有属性(后续会讲)。

场景二:拦截属性访问(Vue2 响应式原理)

var obj = {
  name: "小吴",
  age: 18,
  _address: "泉州市"
};

Object.defineProperty(obj, "address", {
  enumerable: true,
  configurable: true,
  get: function() {
    console.log("获取了一次 address 的值");  // 拦截读取
    return this._address;
  },
  set: function(value) {
    console.log("设置了一次 address 的值");  // 拦截写入
    this._address = value;
  }
});

console.log(obj.address);
// 输出:获取了一次 address 的值
//      泉州市

obj.address = "why";
// 输出:设置了一次 address 的值

console.log(obj.address);
// 输出:获取了一次 address 的值
//      why

核心价值: 这就是 Vue2 响应式系统的底层原理——通过 get/set 拦截属性访问,实现依赖收集和派发更新。


六、学习属性描述符的实战意义

6.1 理解原生 API 的能力边界

所有原生对象的 API 都有属性描述符,这决定了它们的行为:

  • 为什么 Array.prototype 上的方法用 for-in 遍历不出来?(enumerable: false
  • 为什么 Object.prototype.toString 不能被删除?(configurable: false

6.2 读懂技术文档

MDN 文档中大量使用属性描述符来描述 API 特性:

图 2:MDN 文档对 API 能力边界的描述

掌握这些概念后,你能:

  • 快速理解 API 的使用限制
  • 预判代码的行为边界
  • 避免踩坑(比如误删不可配置的属性)

6.3 降低框架学习门槛

React、Vue 等框架文档中会用到这些术语:

图 3:React 文档中的专业术语

学完 JavaScript 高级后,这些词汇对你来说将不再陌生。


七、关键要点总结

  1. 属性描述符分两类:数据描述符(静态值)和存取描述符(动态函数),不能混用
  2. 默认值差异:直接定义的属性默认可配置/可枚举/可写,通过描述符定义的默认都是 false
  3. 核心应用场景
    • 隐藏私有属性(用 get/set 代理访问)
    • 拦截属性访问(实现响应式、日志、校验等)
    • 精准控制对象行为(防删除、防修改、防遍历)
  4. 实战价值:理解原生 API、读懂技术文档、掌握框架原理

八、下一步建议

团队落地建议:

  • 在工具函数库中封装常用的属性控制逻辑(如冻结对象、只读属性等)
  • Code Review 时关注属性描述符的使用是否合理
  • 在复杂对象设计中主动使用描述符提升代码健壮性

后续学习方向:

  • 批量定义属性描述符(Object.defineProperties
  • 对象方法补充(Object.freezeObject.seal 等)
  • 工厂函数与构造函数
  • 原型链与继承机制

下一篇我们将深入构造函数,探索更高效的对象创建方案。

箭头函数与 this 面试题深度解析:从原理到实战

为什么箭头函数如此重要

在现代 JavaScript 开发中,你是否遇到过这些场景:

  • 在 React 组件中,事件处理函数的 this 总是 undefined
  • 在定时器或异步回调中,访问不到外层的 this
  • 看到别人代码中的 var _this = this,不理解为什么要这样写
  • 面试官问"箭头函数和普通函数的区别",只能回答"语法更简洁"

箭头函数是 ES6 引入的最重要特性之一,它不仅仅是语法糖,更是解决了 JavaScript 中 this 绑定的历史难题。在 React、Vue 等现代框架中,箭头函数已经成为标配写法。

本文收益

  • 深入理解箭头函数的 this 绑定机制
  • 掌握箭头函数的各种简写技巧和使用场景
  • 学会判断何时使用箭头函数,何时使用普通函数
  • 通过 4 道经典面试题,建立完整的 this 知识体系
  • 了解箭头函数在实际项目中的最佳实践

一、箭头函数的本质:词法作用域的 this

1.1 什么是箭头函数

箭头函数(Arrow Function)是 ES6 引入的新函数语法,因其使用 => 符号而得名,也被称为"胖箭头"函数。

** 图9-1 箭头函数的箭头**

基础语法结构

// 基础模板
(参数) => { 函数体 }

// 实际示例
const add = (a, b) => {
  return a + b;
}

// 简写形式
const add = (a, b) => a + b;

核心特性

  1. 更简洁的语法:相比传统函数表达式,代码量可减少 30%-50%
  2. 不绑定 this:this 由外层作用域决定,不受调用方式影响
  3. 没有 arguments 对象:需要使用剩余参数 ...args 替代
  4. 不能作为构造函数:不能使用 new 关键字调用

1.2 箭头函数的语法解析

语法结构分解

要素 描述 作用
() 参数列表 定义函数输入。单个参数可省略括号,无参数或多参数必须保留
=> 箭头符号 连接参数和函数体,标识这是箭头函数
{} 函数体 包含执行语句。单条返回语句可省略大括号和 return

两种常见写法对比

// 方式1:内联方式(推荐用于简单逻辑)
var nums = [10, 20, 30, 40]
nums.forEach((value, index, array) => {
  console.log(value, index, array)
})

// 方式2:完整方式(适用于复杂逻辑或需要复用)
var foo = (value, index, array) => {
  console.log(value, index, array)
}
nums.forEach(foo)

选择建议

  • 简单的一次性逻辑:使用内联方式,代码更直观
  • 复杂逻辑或需要复用:抽取为独立函数,提高可维护性
  • 团队协作:优先考虑可读性,而非极致简洁

1.3 箭头函数的三种简写技巧

简写1:省略参数括号

条件:只有一个参数时可省略

// 简写前
nums.forEach((item) => {
  console.log(item)
})

// 简写后
nums.forEach(item => {
  console.log(item)
})

简写2:省略函数体大括号

条件:函数体只有一条语句且需要返回值

// 完整写法
var newNums = nums.filter(item => {
  return item % 2 === 0
})

// 简写(隐式返回)
var newNums = nums.filter(item => item % 2 === 0)

隐式返回:省略大括号后,表达式的结果会自动作为返回值,无需 return 关键字。

实战案例

const books = [
  { title: "Book A", rating: 4.5 },
  { title: "Book B", rating: 3.9 },
  { title: "Book C", rating: 4.7 }
];

// 链式调用 + 箭头函数简写
const titles = books
  .filter(book => book.rating > 4)
  .map(book => book.title);

console.log(titles); // ["Book A", "Book C"]

// 对比:传统写法需要 10+ 行代码
var highRatingBooks = [];
for (var i = 0; i < books.length; i++) {
  if (books[i].rating > 4) {
    highRatingBooks.push(books[i]);
  }
}

var titles2 = [];
for (var i = 0; i < highRatingBooks.length; i++) {
  titles2.push(highRatingBooks[i].title);
}

这种链式调用在 React、Vue 等现代框架中随处可见,是必须掌握的技能。

简写3:返回对象字面量

陷阱:直接返回对象会产生语法冲突

// ❌ 错误写法:大括号被解析为函数体
var bar = () => { name: "小吴", age: 18 }
console.log(bar()) // undefined

// ✅ 正确写法:用小括号包裹对象
var bar = () => ({ name: "why", age: 18 })
console.log(bar()) // { name: "why", age: 18 }

// 或者使用完整写法(推荐用于复杂对象)
var bar = () => {
  return { name: "小吴", age: 18 }
}

** 图9-2 简写3-通俗易懂的写法及结果**

原理解析

  • JavaScript 引擎会将 {} 优先解析为函数体,而非对象字面量
  • 小括号 () 强制将内容视为表达式,避免歧义
  • 类似数学表达式中的括号,改变运算优先级

代码规范建议

  • 简单对象:使用小括号包裹
  • 复杂对象:使用完整 return 语句,提高可读性
  • 避免过度简写,团队协作中可读性优先

二、箭头函数的 this:词法绑定的革命

2.1 箭头函数没有自己的 this

核心概念:箭头函数不创建自己的 this 上下文,而是继承外层作用域的 this。

社区中有两种说法:

  1. "箭头函数没有 this"
  2. "箭头函数的 this 由外层作用域决定"

准确理解

  • 箭头函数本身不绑定 this
  • 箭头函数内的 this 是从外层(非箭头函数)作用域继承而来
  • 这种继承是词法的(静态的),在函数定义时就确定了
var name = "小吴"

var foo = () => {
  console.log(this);
}

foo()                    // window
var obj = { foo: foo }
obj.foo()                // window(不受隐式绑定影响)
foo.call("这是call调用")  // window(不受显式绑定影响)

为什么三种调用方式都是 window?

  1. foo 的外层作用域是全局作用域
  2. 全局作用域的 this 指向 window(浏览器环境)
  3. 箭头函数不受调用方式影响,始终使用外层的 this

2.2 箭头函数 vs 普通函数的 this 对比

普通函数(受调用方式影响)

var name = "小吴"

function foo() {
  console.log(this);
}

var obj = {
  name: "你已经被小吴绑定到obj上啦",
  foo: foo
}

obj.foo() // { name: '你已经被小吴绑定到obj上啦', foo: [Function: foo] }

箭头函数(不受调用方式影响)

var name = "小吴"

var foo = () => {
  console.log(this);
}

var obj = {
  name: "你已经被小吴绑定到obj上啦",
  foo: foo
}

obj.foo() // window

关键差异

  • 普通函数:this 由调用方式决定(隐式绑定生效)
  • 箭头函数:this 由定义位置的外层作用域决定(隐式绑定无效)

2.3 箭头函数解决的经典问题

问题场景:异步回调中的 this 丢失

在 ES6 之前,异步回调中访问外层 this 是一个常见痛点:

// ES5 时代的解决方案:保存 this 引用
var obj = {
  data: [],
  getData: function() {
    var _this = this  // 保存外层 this

    setTimeout(function() {
      var result = ["小吴", 'why', 'JS高级']
      _this.data = result  // 通过闭包访问外层 this
      console.log(_this)
    }, 2000)
  }
}

obj.getData()

为什么需要 var _this = this

  1. setTimeout 的回调函数是独立调用,this 指向 window
  2. 无法直接访问 getData 方法的 this(obj 对象)
  3. 通过变量保存 this,利用闭包机制保持引用

** 图9-3 var _this = this操作内存图**

内存机制解析

  • obj 对象存储在堆内存中
  • getData 方法中的 _this 变量保存了 obj 的引用
  • setTimeout 回调形成闭包,持有 _this 的引用
  • 即使回调函数的 this 指向 window,仍可通过 _this 访问 obj

箭头函数的优雅解决方案

// ES6 箭头函数方案
var obj = {
  data: [],
  getData: function() {
    setTimeout(() => {
      var result = ["小吴", 'why', 'JS高级']
      this.data = result  // 直接使用 this,指向 obj
      console.log(this)
    }, 2000)
  }
}

obj.getData()

优势

  • 无需 var _this = this 的样板代码
  • this 自动指向外层作用域(getData 方法的 this)
  • 代码更简洁,意图更清晰

2.4 实战场景:网络请求中的 this

在实际项目中,网络请求是箭头函数最常见的应用场景:

** 图9-4 正式网络请求存储(this指向)**

典型模式

// Vue 组件中的网络请求
export default {
  data() {
    return {
      userList: []
    }
  },
  methods: {
    fetchUsers() {
      // 使用箭头函数,this 自动指向 Vue 实例
      fetch('/api/users')
        .then(res => res.json())
        .then(data => {
          this.userList = data  // this 指向 Vue 实例
        })
    }
  }
}

// React 类组件中的网络请求
class UserList extends React.Component {
  state = { users: [] }

  fetchUsers = () => {
    // 箭头函数属性,this 自动绑定到组件实例
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        this.setState({ users: data })
      })
  }
}

小结

  • 箭头函数不绑定 this,继承外层作用域的 this
  • 解决了异步回调中 this 丢失的问题
  • 在现代框架中是处理事件和异步操作的标准方案
  • 理解箭头函数的 this 机制,比死记硬背规则更重要

三、箭头函数的使用场景

3.1 适合使用箭头函数的场景

使用场景 描述 示例
回调函数 事件监听、异步处理中保持外层 this setTimeout(() => console.log(this), 1000)
数组操作 配合 map、filter、reduce 等方法 nums.map(n => n * 2)
简洁表达 一行代码完成函数定义 const square = x => x * x
链式调用 Promise 链和流式 API fetch(url).then(res => res.json())
柯里化 简化函数柯里化实现 const add = x => y => x + y

3.2 不适合使用箭头函数的场景

1. 对象方法

// ❌ 错误:this 不指向对象
const obj = {
  name: "小吴",
  sayName: () => {
    console.log(this.name) // undefined
  }
}

// ✅ 正确:使用普通函数
const obj = {
  name: "小吴",
  sayName: function() {
    console.log(this.name) // 小吴
  }
}

2. 原型方法

// ❌ 错误
Person.prototype.sayName = () => {
  console.log(this.name)
}

// ✅ 正确
Person.prototype.sayName = function() {
  console.log(this.name)
}

3. 需要动态 this 的场景

// ❌ 错误:事件处理中需要访问 DOM 元素
button.addEventListener('click', () => {
  this.classList.toggle('active') // this 不是 button
})

// ✅ 正确
button.addEventListener('click', function() {
  this.classList.toggle('active') // this 是 button
})

四、this 面试题深度解析

在学习了箭头函数后,我们已经掌握了 this 的完整知识体系。接下来通过 4 道经典面试题,验证学习成果。

** 图9-5 基础篇面试题大纲**

4.1 面试题1:绑定规则综合考察

题目:判断以下代码的输出

var name = "window"
var person = {
  name: "person",
  sayName: function() {
    console.log(this.name);
  }
};

function sayName() {
  var sss = person.sayName
  sss();                    // ?
  person.sayName();         // ?
  (person.sayName)();       // ?
  (b = person.sayName)();   // ?
}

sayName()

考点分析

  • 隐式绑定
  • 独立函数调用
  • 间接函数引用

逐行解析

var name = "window"
var person = {
  name: "person",
  sayName: function() {
    console.log(this.name);
  }
};

function sayName() {
  var sss = person.sayName
  sss();                    // "window" - 独立调用,默认绑定
  person.sayName();         // "person" - 隐式绑定
  (person.sayName)();       // "person" - 括号不改变隐式绑定
  (b = person.sayName)();   // "window" - 间接引用,独立调用
}

sayName()

详细解释

  1. sss()

    • sss 保存的是函数引用(内存地址)
    • 调用时没有对象前缀,属于独立调用
    • 应用默认绑定,this 指向 window
  2. person.sayName()

    • 通过对象调用方法
    • 应用隐式绑定,this 指向 person
  3. (person.sayName)()

    • 括号只是将表达式视为整体,不改变调用方式
    • 本质仍是 person.sayName()
    • 应用隐式绑定,this 指向 person
  4. (b = person.sayName)()

    • 赋值表达式返回函数引用
    • 相当于先执行 b = person.sayName,再执行 b()
    • 属于独立调用,应用默认绑定,this 指向 window

关键要点

  • 函数引用赋值后,调用方式决定 this
  • 括号不改变调用方式,除非内部是赋值表达式
  • 间接引用是独立调用的一种形式

4.2 面试题2:箭头函数与显式绑定

题目:判断以下代码的输出

var name = 'window'

var person1 = {
  name: 'person1',
  foo1: function () {
    console.log(this.name)
  },
  foo2: () => console.log(this.name),
  foo3: function () {
    return function () {
      console.log(this.name)
    }
  },
  foo4: function () {
    return () => {
      console.log(this.name)
    }
  }
}

var person2 = { name: 'person2' }

person1.foo1();                  // ?
person1.foo1.call(person2);      // ?

person1.foo2();                  // ?
person1.foo2.call(person2);      // ?

person1.foo3()();                // ?
person1.foo3.call(person2)();    // ?
person1.foo3().call(person2);    // ?

person1.foo4()();                // ?
person1.foo4.call(person2)();    // ?
person1.foo4().call(person2);    // ?

答案与解析

person1.foo1();                  // "person1" - 隐式绑定
person1.foo1.call(person2);      // "person2" - 显式绑定优先级更高

person1.foo2();                  // "window" - 箭头函数,外层是全局
person1.foo2.call(person2);      // "window" - 箭头函数不受 call 影响

person1.foo3()();                // "window" - 返回普通函数,独立调用
person1.foo3.call(person2)();    // "window" - 返回的函数仍是独立调用
person1.foo3().call(person2);    // "person2" - 对返回的函数显式绑定

person1.foo4()();                // "person1" - 箭头函数继承 foo4 的 this
person1.foo4.call(person2)();    // "person2" - foo4 的 this 被改为 person2
person1.foo4().call(person2);    // "person1" - 箭头函数不受 call 影响

核心考点

  1. foo1 系列:普通函数的隐式绑定和显式绑定

    • 显式绑定(call)优先级高于隐式绑定
  2. foo2 系列:箭头函数的特性

    • 箭头函数定义在对象字面量中,外层作用域是全局
    • call/apply/bind 无法改变箭头函数的 this
  3. foo3 系列:返回普通函数

    • foo3() 返回一个新函数,再调用 () 是独立调用
    • foo3().call(person2) 对返回的函数进行显式绑定
  4. foo4 系列:返回箭头函数

    • 箭头函数的 this 取决于 foo4 执行时的 this
    • foo4.call(person2)() 改变了 foo4 的 this,箭头函数继承这个 this
    • foo4().call(person2) 无法改变箭头函数的 this

记忆技巧

  • 箭头函数的 this 在定义时确定(词法绑定)
  • 普通函数的 this 在调用时确定(动态绑定)
  • 连续调用 ()() 时,每个 () 都是一次独立的调用判断

4.3 面试题3:new 绑定与箭头函数

题目:判断以下代码的输出

var name = 'window'

function Person(name) {
  this.name = name
  this.foo1 = function () {
    console.log(this.name)
  },
  this.foo2 = () => console.log(this.name),
  this.foo3 = function () {
    return function () {
      console.log(this.name)
    }
  },
  this.foo4 = function () {
    return () => {
      console.log(this.name)
    }
  }
}

var person1 = new Person('person1')
var person2 = new Person('person2')

person1.foo1()                   // ?
person1.foo1.call(person2)       // ?

person1.foo2()                   // ?
person1.foo2.call(person2)       // ?

person1.foo3()()                 // ?
person1.foo3.call(person2)()     // ?
person1.foo3().call(person2)     // ?

person1.foo4()()                 // ?
person1.foo4.call(person2)()     // ?
person1.foo4().call(person2)     // ?

答案与解析

person1.foo1()                   // "person1" - 隐式绑定
person1.foo1.call(person2)       // "person2" - 显式绑定

person1.foo2()                   // "person1" - 箭头函数继承构造函数的 this
person1.foo2.call(person2)       // "person1" - 箭头函数不受 call 影响

person1.foo3()()                 // "window" - 独立调用
person1.foo3.call(person2)()     // "window" - 独立调用
person1.foo3().call(person2)     // "person2" - 显式绑定

person1.foo4()()                 // "person1" - 箭头函数继承 foo4 的 this
person1.foo4.call(person2)()     // "person2" - foo4 的 this 被改变
person1.foo4().call(person2)     // "person1" - 箭头函数不受 call 影响

关键理解

  1. new 绑定创建新对象

    • new Person('person1') 创建新对象,this 指向该对象
    • 构造函数中的 this.foo2 是箭头函数,继承构造函数的 this
  2. 箭头函数在构造函数中的特殊性

    • foo2 是箭头函数,定义在构造函数中
    • 外层作用域是构造函数,this 指向 new 创建的对象
    • 因此 person1.foo2() 输出 "person1"
  3. 与对象字面量的区别

    • 对象字面量中的箭头函数,外层是全局作用域
    • 构造函数中的箭头函数,外层是构造函数作用域

4.4 面试题4:嵌套对象中的 this

题目:判断以下代码的输出

var name = 'window'

function Person(name) {
  this.name = name
  this.obj = {
    name: 'obj',
    foo1: function () {
      return function () {
        console.log(this.name)
      }
    },
    foo2: function () {
      return () => {
        console.log(this.name)
      }
    }
  }
}

var person1 = new Person('person1')
var person2 = new Person('person2')

person1.obj.foo1()()                 // ?
person1.obj.foo1.call(person2)()     // ?
person1.obj.foo1().call(person2)     // ?

person1.obj.foo2()()                 // ?
person1.obj.foo2.call(person2)()     // ?
person1.obj.foo2().call(person2)     // ?

答案与解析

person1.obj.foo1()()                 // "window" - 独立调用
person1.obj.foo1.call(person2)()     // "window" - 返回的函数独立调用
person1.obj.foo1().call(person2)     // "person2" - 显式绑定

person1.obj.foo2()()                 // "obj" - 箭头函数继承 foo2 的 this
person1.obj.foo2.call(person2)()     // "person2" - foo2 的 this 被改变
person1.obj.foo2().call(person2)     // "obj" - 箭头函数不受 call 影响

难点解析

  1. person1.obj.foo2()()

    • person1.obj.foo2() 通过 obj 调用,this 指向 obj
    • 返回箭头函数,继承 foo2 的 this(obj)
    • 输出 "obj"
  2. person1.obj.foo2.call(person2)()

    • foo2.call(person2) 改变 foo2 的 this 为 person2
    • 返回箭头函数,继承 foo2 的 this(person2)
    • 输出 "person2"
  3. person1.obj.foo2().call(person2)

    • person1.obj.foo2() 返回箭头函数,this 已确定为 obj
    • .call(person2) 无法改变箭头函数的 this
    • 输出 "obj"

判断技巧

  • 看到 ()() 连续调用,先判断第一个 () 返回什么
  • 如果返回箭头函数,this 取决于外层函数执行时的 this
  • 如果返回普通函数,this 取决于第二个 () 的调用方式

五、实战应用与最佳实践

5.1 箭头函数的使用决策树

是否需要动态 this?
├─ 是 → 使用普通函数
│   ├─ 对象方法
│   ├─ 原型方法
│   └─ 事件处理(需要访问 DOM 元素)
│
└─ 否 → 考虑使用箭头函数
    ├─ 回调函数(保持外层 this)
    ├─ 数组方法(map、filter 等)
    ├─ Promise 链
    └─ 简单的工具函数

5.2 常见陷阱与解决方案

陷阱1:对象方法使用箭头函数

// ❌ 错误
const calculator = {
  value: 0,
  add: (num) => {
    this.value += num  // this 不指向 calculator
  }
}

// ✅ 正确
const calculator = {
  value: 0,
  add(num) {
    this.value += num
  }
}

陷阱2:原型方法使用箭头函数

// ❌ 错误
function Person(name) {
  this.name = name
}
Person.prototype.sayName = () => {
  console.log(this.name)  // this 不指向实例
}

// ✅ 正确
Person.prototype.sayName = function() {
  console.log(this.name)
}

陷阱3:需要 arguments 对象

// ❌ 错误:箭头函数没有 arguments
const sum = () => {
  console.log(arguments)  // ReferenceError
}

// ✅ 正确:使用剩余参数
const sum = (...args) => {
  console.log(args)
  return args.reduce((a, b) => a + b, 0)
}

5.3 框架中的最佳实践

React 类组件

class MyComponent extends React.Component {
  // ✅ 推荐:箭头函数属性,自动绑定 this
  handleClick = () => {
    this.setState({ clicked: true })
  }

  // ❌ 不推荐:需要在构造函数中手动绑定
  handleClick() {
    this.setState({ clicked: true })
  }
  constructor() {
    super()
    this.handleClick = this.handleClick.bind(this)
  }
}

Vue 组件

export default {
  data() {
    return { count: 0 }
  },
  methods: {
    // ✅ 推荐:普通方法,this 自动指向组件实例
    increment() {
      this.count++
    },

    // ✅ 推荐:异步操作中使用箭头函数
    async fetchData() {
      const data = await fetch('/api/data')
        .then(res => res.json())  // 箭头函数保持 this
      this.data = data
    }
  }
}

5.4 性能优化建议

避免在渲染中创建箭头函数

// ❌ 不推荐:每次渲染都创建新函数
render() {
  return (
    <button onClick={() => this.handleClick()}>
      点击
    </button>
  )
}

// ✅ 推荐:使用箭头函数属性
handleClick = () => {
  // ...
}
render() {
  return <button onClick={this.handleClick}>点击</button>
}

5.5 团队协作规范

代码审查检查点

  • 对象方法是否误用箭头函数
  • 事件处理是否需要访问 DOM 元素(this)
  • 箭头函数是否在不必要的地方使用
  • 是否有过度简写影响可读性

编码规范建议

  1. 对象方法统一使用简写语法:method() {} 而非 method: function() {}
  2. 回调函数优先使用箭头函数
  3. 需要动态 this 时明确使用普通函数
  4. 复杂逻辑避免过度简写,保持可读性

六、总结与进阶路线

6.1 核心要点回顾

箭头函数的本质

  • 更简洁的函数语法
  • 不绑定 this,继承外层作用域的 this
  • 没有 arguments 对象,使用剩余参数替代
  • 不能作为构造函数使用

this 绑定规则完整体系

  1. 默认绑定:独立调用 → 全局对象或 undefined
  2. 隐式绑定:对象方法调用 → 调用对象
  3. 显式绑定:call/apply/bind → 指定对象
  4. new 绑定:构造函数调用 → 新对象
  5. 箭头函数:不绑定 this → 继承外层作用域

优先级:new > 显式 > 隐式 > 默认 > 箭头函数(不参与优先级)

使用原则

  • 需要动态 this:使用普通函数
  • 需要保持外层 this:使用箭头函数
  • 简单工具函数:优先箭头函数
  • 对象/原型方法:使用普通函数

6.2 团队落地建议

阶段一:知识普及(1 周)

  • 组织箭头函数专题分享
  • 整理常见误用案例库
  • 在代码审查中重点关注 this 相关问题

阶段二:规范制定(1 周)

  • 制定箭头函数使用规范
  • 配置 ESLint 规则自动检测
  • 建立最佳实践文档

阶段三:工具支持(持续)

  • 使用 TypeScript 减少 this 错误
  • 引入现代框架减少 this 依赖
  • 建立单元测试覆盖 this 逻辑

阶段四:持续优化(持续)

  • 定期回顾 this 相关 bug
  • 更新团队知识库
  • 在新人培训中加入专题

6.3 进阶学习路线

下一步学习内容

  1. 手写实现 call/apply/bind

    • 理解显式绑定的内部机制
    • 掌握 arguments 对象的使用
    • 实现函数柯里化
  2. 深入理解作用域

    • 词法作用域 vs 动态作用域
    • 闭包与箭头函数的关系
    • 作用域链的查找机制
  3. ES6+ 新特性

    • 解构赋值与箭头函数
    • 默认参数与剩余参数
    • 模板字符串与标签函数
  4. 框架源码分析

    • React Hooks 如何避免 this
    • Vue 3 Composition API 的设计思想
    • 现代框架的 this 处理策略

推荐资源

  • 《你不知道的 JavaScript(上卷)》- this 和对象原型
  • MDN Web Docs - 箭头函数
  • JavaScript.info - 箭头函数基础

6.4 自测题

基础题

  1. 以下代码输出什么?
const obj = {
  name: "obj",
  getName: () => this.name
}
console.log(obj.getName())
  1. 如何修改使其正确输出 "obj"?

进阶题

  1. 解释为什么以下代码无法正常工作:
function Timer() {
  this.seconds = 0
  setInterval(() => {
    this.seconds++
  }, 1000)
}
const timer = new Timer()
  1. 在 React 中,以下两种写法有什么区别?
// 方式1
<button onClick={() => this.handleClick()}>

// 方式2
<button onClick={this.handleClick}>

答案

  1. undefined(箭头函数的 this 指向全局)
  2. 使用普通函数:getName: function() { return this.name }
  3. 代码可以正常工作,箭头函数继承构造函数的 this
  4. 方式1 每次渲染创建新函数,性能较差;方式2 需要确保 handleClick 已绑定 this

七、写在最后

箭头函数是 ES6 最重要的特性之一,它不仅简化了语法,更从根本上解决了 JavaScript 中 this 绑定的痛点。

关键心态

  • 理解箭头函数的本质:词法作用域的 this
  • 不要盲目使用箭头函数,根据场景选择
  • 在现代开发中,优先考虑函数式编程思想
  • 善用工具和框架,减少对 this 的依赖

实践建议

  • 在真实项目中刻意练习箭头函数的使用
  • 遇到 this 问题时,先判断是否适合用箭头函数
  • 代码审查时,关注箭头函数的使用场景
  • 定期回顾本文,加深理解

掌握箭头函数和 this,是成为高级前端工程师的必经之路。接下来,我们将手写实现 call/apply/bind,深入理解显式绑定的内部机制。

持续学习,保持好奇心,我们下期见!

深入理解 JavaScript 中的 this 绑定机制:从原理到实战

为什么要读这篇文章

在日常开发中,你是否遇到过这些困惑:

  • 为什么同一个函数,在不同地方调用,this 指向完全不同?
  • 箭头函数的 this 为什么"不听话"?
  • 面试官问"this 的绑定规则优先级"时,如何系统回答?

this 是 JavaScript 中最容易被误解的概念之一。它不像其他语言那样简单地指向"当前对象",而是具有动态绑定的特性。掌握 this 的核心规则,不仅能让你写出更优雅的代码,还能在排查 bug 时快速定位问题。

本文收益

  • 掌握 this 的 4 种绑定规则及其优先级
  • 理解常见场景下的 this 指向(事件监听、定时器、数组方法等)
  • 学会手写 call/apply/bind 实现
  • 建立完整的 this 知识体系,应对各种边界情况

一、this 的本质:动态绑定的执行上下文

1.1 什么是 this

this 是函数执行时指向"当前执行上下文"的对象引用

这句话包含三个关键信息:

  1. 执行时确定:this 的值在函数被调用时才确定,而非定义时
  2. 执行上下文:每次函数调用都会创建一个函数执行上下文(FEC),this 是其中的一个属性
  3. 动态绑定:同一个函数在不同调用方式下,this 可能指向不同对象

1.2 为什么需要 this

在面向对象编程中,Java、C++ 等语言的 this 通常只出现在类的实例方法中,指向当前实例。但 JavaScript 的 this 更加灵活,这种灵活性既是优势也是挑战。

使用 this 的核心价值

// 不使用 this:代码耦合度高
var obj = {
  name: "小吴",
  eating: function() {
    console.log(obj.name + "在吃东西");
  },
  running: function() {
    console.log(obj.name + "在跑步");
  }
}

// 使用 this:代码可复用性强
var obj = {
  name: "小吴",
  eating: function() {
    console.log(this.name + "在吃东西");
  },
  running: function() {
    console.log(this.name + "在跑步");
  }
}

obj.eating()  // 小吴在吃东西
obj.running() // 小吴在跑步

对比分析

不使用 this 的问题:

  • 方法内部硬编码了对象名称(obj.name)
  • 无法复用方法到其他对象
  • 对象重命名时需要修改所有方法内部代码

使用 this 的优势:

  • 方法与具体对象解耦,提高可维护性
  • 同一套方法可以被多个对象共享
  • 符合面向对象的封装原则

1.3 全局作用域中的 this

在深入绑定规则前,先了解全局 this 的特殊性:

  • 浏览器环境:this 指向 window 对象
  • Node.js 环境:this 指向空对象 {}
// 浏览器环境
console.log(this === window); // true

// Node.js 环境
console.log(this); // {}
console.log(this === module.exports); // true
console.log(this === global); // false

Node.js 中的特殊机制

Node.js 将每个文件视为一个模块,执行时会包装成如下形式:

(function(exports, require, module, __filename, __dirname) {
  // 你的模块代码
  // 顶层 this 被绑定到 module.exports
});

这就是为什么 Node.js 模块顶层的 this 指向 module.exports(初始为空对象),而非 global 对象。

函数内部的 this

function foo() {
  console.log(this);
}

foo.apply("小吴"); // [String: '小吴']

文件被 Node 执行时,会调用 foo.apply({}),将空对象传入作为 this。

1.4 同一函数,不同 this

这是理解 this 的关键案例:

function foo() {
  console.log(this);
}

// 1. 直接调用
foo() // window(浏览器)或 global(Node.js 非严格模式)

// 2. 对象方法调用
var obj = {
  name: "小吴",
  foo: foo
}
obj.foo() // { name: '小吴', foo: [Function: foo] }

// 3. 显式绑定
foo.apply("XiaoWu") // [String: 'XiaoWu']

** 图8-3 函数的三种调用方式效果**

核心结论

  1. this 的绑定与函数定义位置无关
  2. this 的绑定与函数调用方式和调用位置有关
  3. this 是在运行时动态绑定的

执行上下文中的 this

** 图8-4 函数调用内存图**

在函数执行上下文(FEC)中,除了作用域链、变量对象(AO)等,还包含 this 绑定。

二、this 的四种绑定规则

掌握 this 的核心在于理解这四种绑定规则。只有显式绑定可以人为改变 this 指向,其他三种规则的 this 指向是固定的。

2.1 规则一:默认绑定

适用场景:独立函数调用(函数没有被绑定到任何对象上)

绑定结果

  • 非严格模式:指向全局对象(浏览器为 window,Node.js 为 global)
  • 严格模式:指向 undefined

案例 1:最基础的独立调用

function foo() {
  console.log(this);
}

foo() // window(浏览器)

案例 2:函数调用链中的独立调用

function foo1() {
  console.log("foo1", this);
}

function foo2() {
  console.log("foo2", this);
  foo1()
}

function foo3() {
  console.log("foo3", this);
  foo2()
}

foo3()
// 输出:
// foo3 window
// foo2 window
// foo1 window

** 图8-5 案例2代码结果**

虽然函数之间有调用关系,但每个函数都是独立调用的,因此 this 都指向 window。

案例 3:对象方法赋值后的独立调用

var obj = {
  name: "小吴",
  foo: function() {
    console.log(this);
  }
}

var fn = obj.foo
fn() // window

关键理解:this 指向与函数定义位置无关,只与调用方式有关。虽然 foo 定义在 obj 中,但 fn() 是独立调用,因此 this 指向 window。

案例 4:函数引用的独立调用

function foo() {
  console.log(this);
}

var obj = {
  name: "小吴",
  foo: foo
}

var bar = obj.foo
bar() // window

与案例 3 本质相同,bar 获取的是函数引用,调用时是独立调用。

案例 5:闭包中的独立调用

function foo() {
  function bar() {
    console.log(this);
  }
  return bar
}

var fn = foo()
fn() // window

// 改变调用方式后
var obj = {
  name: "why",
  age: fn
}

obj.age() // { name: 'why', age: [Function: bar] }

闭包函数的 this 不是固定指向 window,而是取决于调用方式。这打破了"闭包必定指向 window"的误解。

小结

  • 默认绑定的判断标准:函数是否独立调用(没有通过对象调用,没有使用 call/apply/bind,没有使用 new)
  • 独立调用的 this 指向全局对象(非严格模式)或 undefined(严格模式)
  • 函数定义位置不影响 this,只有调用方式才影响

2.2 规则二:隐式绑定

适用场景:通过对象调用方法(obj.method())

绑定结果:this 指向调用该方法的对象

核心原则:哪个对象发起的方法调用,this 就指向谁。

案例 1:基础隐式绑定

function foo() {
  console.log(this);
}

var obj = {
  name: "why",
  foo: foo
}

obj.foo() // { name: 'why', foo: [Function: foo] }

** 图8-6 隐式绑定案例1效果图**

JavaScript 引擎会将 obj 对象绑定到 foo 函数的 this 中。

案例 2:方法中使用 this

var obj = {
  name: "小吴",
  eating: function() {
    console.log(this.name + "在吃东西");
  },
  running: function() {
    console.log(this.name + "在跑步");
  }
}

obj.eating()  // 小吴在吃东西
obj.running() // 小吴在跑步

// 解除绑定关系
var fn = obj.eating
fn() // undefined在吃东西(this.name 为 undefined)

** 图8-7 obj与eating绑定关系解除前后对比**

一旦解除对象与方法的绑定关系,this 指向就会改变。

案例 3:多层对象调用

var obj1 = {
  name: "obj1",
  foo: function() {
    console.log(this);
  }
}

var obj2 = {
  name: "obj2",
  bar: obj1.foo
}

obj2.bar() // { name: 'obj2', bar: [Function: foo] }

** 图8-8 案例3控制台打印结果**

虽然 bar 引用的是 obj1.foo,但调用时是通过 obj2 发起的,因此 this 指向 obj2。

小结

  • 隐式绑定的判断标准:函数是否通过对象调用(obj.method())
  • this 指向最后调用该方法的对象
  • 赋值操作会丢失隐式绑定,转为默认绑定

2.3 规则三:显式绑定

适用场景:使用 call、apply、bind 方法主动指定 this

绑定结果:this 指向传入的第一个参数对象

隐式绑定是"被动"的,需要对象内部有函数引用才能绑定。显式绑定则是"主动"的,可以直接指定 this 指向。

2.3.1 call 和 apply 的使用

call 语法func.call(thisArg, arg1, arg2, ...) apply 语法func.apply(thisArg, [argsArray])

核心区别:参数传递方式不同

  • call:参数逐个传递
  • apply:参数以数组形式传递
function sum(num1, num2) {
  console.log(num1 + num2, this)
}

sum.call("call", 20, 30)   // 50 [String: 'call']
sum.apply("apply", [20, 30]) // 50 [String: 'apply']

与直接调用的区别

function foo() {
  console.log("函数被调用了", this);
}

var obj = {
  name: "why"
}

foo()              // window
foo.apply("小吴")  // [String: '小吴']
foo.call(obj)      // { name: 'why' }

** 图8-9 直接调用与apply、call调用的不同**

2.3.2 bind 的使用

当需要多次使用相同的 this 绑定时,bind 比 call/apply 更方便。

bind 语法func.bind(thisArg[, arg1[, arg2[, ...]]])

特点

  • 返回一个新函数,不会立即执行
  • 新函数的 this 被永久绑定到指定对象
  • 可以预设部分参数(柯里化)
function foo() {
  console.log(this)
}

// 使用 call 需要重复传参
// foo.call("小吴")
// foo.call("小吴")
// foo.call("小吴")

// 使用 bind 只需绑定一次
var newFoo = foo.bind("小吴")
newFoo() // [String: '小吴']
newFoo() // [String: '小吴']

bind 的特殊性

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

var newFoo = foo.bind("小吴")
var bar = foo

console.log(bar === foo)    // true
console.log(newFoo === foo) // false

bind 返回的是一个新函数,与原函数不是同一个引用。这证明 bind 不会修改原函数,而是创建一个新的绑定函数。

2.3.3 三者对比

方法 执行时机 参数形式 返回值 使用场景
call 立即执行 逐个传递 函数执行结果 一次性调用,参数较少
apply 立即执行 数组传递 函数执行结果 一次性调用,参数较多或动态参数
bind 不执行 逐个传递 新函数 需要多次调用或延迟执行

小结

  • 显式绑定可以主动改变 this 指向
  • call/apply 立即执行,bind 返回新函数
  • 显式绑定的优先级高于隐式绑定和默认绑定

2.4 规则四:new 绑定

适用场景:使用 new 关键字调用函数(构造函数)

绑定结果:this 指向新创建的对象

new 的执行过程

  1. 创建一个全新的对象
  2. 将这个对象的原型指向构造函数的 prototype
  3. 将 this 绑定到这个新对象
  4. 执行构造函数代码
  5. 如果构造函数没有返回对象,则返回这个新对象
function Person(name, age) {
  this.name = name
  this.age = age
}

Person() // 普通调用,this 指向 window

var p1 = new Person("小吴", 20)
console.log(p1.name, p1.age) // 小吴 20

var p2 = new Person("why", 35)
console.log(p2.name, p2.age) // why 35

** 图8-10 正常调用与new调用区别**

使用 new 调用时,JavaScript 会创建一个新对象并将其绑定到函数的 this 上。

小结

  • new 绑定会创建新对象并绑定到 this
  • 构造函数只是使用 new 调用的普通函数
  • new 绑定的优先级高于隐式绑定

三、常见场景中的 this 分析

3.1 setTimeout 定时器

// 普通函数
setTimeout(function() {
  console.log("普通函数的this", this); // window(浏览器)或 global(Node.js)
}, 1000)

// 箭头函数
setTimeout(() => {
  console.log("箭头函数的this", this); // 取决于外层作用域
}, 2000)

** 图8-11 node环境下的结果**

原理:setTimeout 内部不会绑定特定的 this,回调函数是独立调用,因此遵循默认绑定规则。

3.2 DOM 事件监听

const boxDiv = document.querySelector(".box")

// 方式1:onclick(只能绑定一个)
boxDiv.onclick = function() {
  console.log(this); // boxDiv 元素对象
}

// 方式2:addEventListener(可以绑定多个)
boxDiv.addEventListener('click', function() {
  console.log(this); // boxDiv 元素对象
})

** 图8-12 监听的对象**

原理:浏览器内部会使用 fn.call(boxDiv) 的方式调用回调函数,将 DOM 元素绑定到 this。

3.3 数组高阶函数

var names = ["ABC", '小吴', 'why']

// 不传第二个参数
names.forEach(function(item) {
  console.log("item", this); // window(三次)
})

// 传入第二个参数绑定 this
names.forEach(function(item) {
  console.log("item", this); // [String: '小吴'](三次)
}, "小吴")

** 图8-13 forEach不加第二个参数**

** 图8-14 forEach加第二个参数**

常见数组方法的 this 绑定

names.forEach(function() {
  console.log("forEach", this);
}, "小吴")

names.map(function() {
  console.log("map", this);
}, "小吴")

names.filter(function() {
  console.log("filter", this);
}, "小吴")

names.find(function() {
  console.log("find", this);
}, "小吴")

** 图8-16 forEach map filter find高阶函数对比情况**

** 图8-15 编辑器提供的语法提示**

实战建议

  • 大多数数组方法的最后一个参数用于绑定 this
  • 使用 TypeScript 或现代编辑器可以看到参数提示
  • 不需要死记硬背,看 API 文档或编辑器提示即可

四、this 绑定规则的优先级

当多个规则同时适用时,需要了解优先级来判断最终的 this 指向。

4.1 优先级排序

从高到低:new 绑定 > 显式绑定 > 隐式绑定> 默认绑定

4.2 优先级验证

1. 显式绑定 > 隐式绑定

var obj = {
  name: "小吴",
  foo: function() {
    console.log(this);
  }
}

obj.foo() // { name: '小吴', foo: [Function: foo] }

// call/apply 优先级更高
obj.foo.call("我是why") // [String: '我是why']

// bind 优先级更高
var bar = obj.foo.bind("小吴666")
bar() // [String: '小吴666']

更明显的对比

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

var obj1 = {
  name: "这是bind更明显的比较",
  foo: foo.bind("why")
}

obj1.foo() // [String: 'why']

虽然通过 obj1 调用(隐式绑定),但 foo 已经被 bind 绑定(显式绑定),最终 this 指向 "why"。

2. new 绑定 > 隐式绑定

var obj = {
  name: "why的JS高级课程很不错,强烈推荐来看",
  foo: function() {
    console.log(this);
  }
}

var f = new obj.foo() // foo {}
obj.foo() // { name: '...', foo: [Function: foo] }

** 图8-17 new绑定优先级高于隐式绑定**

3. new 绑定 > 显式绑定(bind)

注意:new 不能与 call/apply 一起使用(都是立即调用函数),只能与 bind 比较。

function foo() {
  console.log(this);
}

var bar = foo.bind("测试一下")
bar() // [String: '测试一下']

var obj = new bar() // foo {}

new 调用时会找到原函数(foo),将其作为构造函数,创建新对象并绑定到 this。

4.3 优先级总结表

绑定类型 描述 优先级 判断方式
new 绑定 使用 new 关键字调用 最高 new func()
显式绑定 call/apply/bind 中高 func.call(obj)
隐式绑定 对象方法调用 中低 obj.func()
默认绑定 独立函数调用 最低 func()

记忆技巧:越主动的绑定方式,优先级越高。

五、特殊情况与边界处理

5.1 忽略显式绑定

当 call/apply/bind 传入 null 或 undefined 时,会被忽略,应用默认绑定规则。

function foo() {
  console.log(this);
}

foo()                // window
foo.apply(null)      // window
foo.apply(undefined) // window

使用场景

  • 不关心 this 指向,只想使用 apply 传递数组参数
  • 使用 bind 进行柯里化,不需要绑定 this

安全实践:传入空对象 Object.create(null) 代替 null,避免意外修改全局对象。

5.2 间接函数引用

赋值表达式返回的是函数引用,调用时属于独立调用。

var obj1 = {
  name: "obj1",
  foo: function() {
    console.log(this);
  }
}

var obj2 = {
  name: "obj2"
}

obj2.foo = obj1.foo
obj2.foo() // { name: 'obj2', foo: [Function: foo] }

// 间接引用
(obj2.foo = obj1.foo)() // window

(obj2.foo = obj1.foo) 返回函数引用,然后立即调用,属于独立调用。

代码规范提醒

var obj2 = {
  name: "obj2"
}
(obj2.foo = obj1.foo)()
// 如果 obj2 后面没有分号,会被解析为:
// var obj2 = { name: "obj2" }(obj2.foo = obj1.foo)()
// 导致错误

解决方案:在对象字面量后加分号,或使用 ESLint 等工具强制规范。

5.3 经典测试题

function foo(el) {
  console.log(el, this);
}

var obj = {
  id: "XiaoWu"
};

[1, 2, 3].forEach(foo, obj)
// 报错:Uncaught TypeError: Cannot read properties of undefined

问题原因:JavaScript 解析器将 [1,2,3] 解释为访问 obj 的属性。

解决方案

// 方案1:使用变量
var names = [1, 2, 3]
names.forEach(foo, obj)

// 方案2:在 obj 后加分号
var obj = {
  id: "XiaoWu"
};
[1, 2, 3].forEach(foo, obj)

这是 JavaScript 自动分号插入(ASI)机制的经典陷阱。

六、实战应用与最佳实践

6.1 判断 this 的决策树

在实际开发中,按以下顺序判断 this 指向:

1. 函数是否使用 new 调用?
   → 是:this 指向新创建的对象

2. 函数是否通过 call/apply/bind 调用?
   → 是:this 指向传入的第一个参数(null/undefined 除外)

3. 函数是否通过对象调用(obj.method())?
   → 是:this 指向该对象

4. 以上都不是?
   → 默认绑定:非严格模式指向全局对象,严格模式为 undefined

6.2 常见陷阱与解决方案

陷阱 1:事件回调中丢失 this

class Button {
  constructor(text) {
    this.text = text
  }

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

const btn = new Button("点击我")
document.querySelector(".btn").addEventListener('click', btn.handleClick)
// 点击后输出 undefined,因为 this 指向 DOM 元素

解决方案

// 方案1:使用 bind
document.querySelector(".btn").addEventListener('click', btn.handleClick.bind(btn))

// 方案2:使用箭头函数
document.querySelector(".btn").addEventListener('click', () => btn.handleClick())

// 方案3:在构造函数中绑定
class Button {
  constructor(text) {
    this.text = text
    this.handleClick = this.handleClick.bind(this)
  }

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

陷阱 2:定时器中的 this

var obj = {
  name: "小吴",
  delayLog: function() {
    setTimeout(function() {
      console.log(this.name) // undefined
    }, 1000)
  }
}

obj.delayLog()

解决方案

// 方案1:保存 this 引用
delayLog: function() {
  var self = this
  setTimeout(function() {
    console.log(self.name) // 小吴
  }, 1000)
}

// 方案2:使用箭头函数(推荐)
delayLog: function() {
  setTimeout(() => {
    console.log(this.name) // 小吴
  }, 1000)
}

// 方案3:使用 bind
delayLog: function() {
  setTimeout(function() {
    console.log(this.name) // 小吴
  }.bind(this), 1000)
}

陷阱 3:数组方法中的 this

var obj = {
  name: "小吴",
  friends: ["张三", "李四"],
  printFriends: function() {
    this.friends.forEach(function(friend) {
      console.log(this.name + "的朋友:" + friend)
      // undefined的朋友:张三
      // undefined的朋友:李四
    })
  }
}

解决方案

// 方案1:传入 thisArg 参数
printFriends: function() {
  this.friends.forEach(function(friend) {
    console.log(this.name + "的朋友:" + friend)
  }, this)
}

// 方案2:使用箭头函数(推荐)
printFriends: function() {
  this.friends.forEach(friend => {
    console.log(this.name + "的朋友:" + friend)
  })
}

6.3 团队协作规范建议

1. 代码审查检查点

  • 事件监听器是否正确绑定 this
  • 定时器回调是否需要保持 this 上下文
  • 数组方法回调是否需要访问外层 this

2. 编码规范

  • 优先使用箭头函数处理回调中的 this 问题
  • 避免在构造函数外使用 bind,影响性能
  • 对象字面量后统一加分号,避免 ASI 陷阱

3. TypeScript 辅助

class Component {
  name: string = "组件"

  // 使用箭头函数属性,自动绑定 this
  handleClick = () => {
    console.log(this.name)
  }
}

6.4 性能优化建议

bind 的性能开销

// ❌ 不推荐:每次渲染都创建新函数
render() {
  return <button onClick={this.handleClick.bind(this)}>点击</button>
}

// ✅ 推荐:在构造函数中绑定一次
constructor() {
  this.handleClick = this.handleClick.bind(this)
}

// ✅ 推荐:使用箭头函数属性
handleClick = () => {
  // ...
}

call/apply 的选择

  • 参数少于 3 个:使用 call(性能略优)
  • 参数多或动态参数:使用 apply
  • 需要多次调用:使用 bind

七、总结与进阶路线

7.1 核心要点回顾

this 的本质

  • this 是函数执行时的上下文对象引用
  • 在函数调用时动态绑定,与定义位置无关
  • 不同调用方式决定不同的 this 指向

四种绑定规则

  1. 默认绑定:独立调用 → 全局对象或 undefined
  2. 隐式绑定:对象方法调用 → 调用对象
  3. 显式绑定:call/apply/bind → 指定对象
  4. new 绑定:构造函数调用 → 新创建的对象

优先级:new > 显式 > 隐式 > 默认

特殊情况

  • null/undefined 会被忽略,应用默认绑定
  • 间接引用会导致默认绑定
  • 箭头函数不遵循这些规则(继承外层作用域的 this)

7.2 团队落地建议

阶段一:知识普及(1-2 周)

  • 组织内部分享会,讲解 this 的四种规则
  • 整理常见陷阱案例库,供团队参考
  • 在代码审查中重点关注 this 相关问题

阶段二:规范制定(1 周)

  • 制定团队编码规范(箭头函数使用场景、bind 使用时机等)
  • 配置 ESLint 规则,自动检测潜在问题
  • 建立 this 相关的最佳实践文档

阶段三:工具支持(持续)

  • 引入 TypeScript,利用类型系统减少 this 错误
  • 使用现代框架(React Hooks、Vue 3 Composition API)减少 this 依赖
  • 建立单元测试覆盖 this 相关逻辑

阶段四:持续优化(持续)

  • 定期回顾 this 相关 bug,总结经验
  • 更新团队知识库,补充新的边界情况
  • 在新人培训中加入 this 专题

7.3 进阶学习路线

下一步学习内容

  1. 箭头函数深入

    • 箭头函数为什么没有自己的 this
    • 箭头函数的词法作用域绑定
    • 箭头函数的使用场景与限制
  2. 手写实现

    • 手写 call/apply/bind 方法
    • 理解 arguments 对象
    • 实现 new 操作符
  3. 原型与继承

    • 原型链中的 this
    • 继承模式中的 this 处理
    • ES6 class 中的 this
  4. 框架中的 this

    • React 中的 this 绑定策略
    • Vue 中的 this 代理机制
    • 现代框架如何减少 this 依赖

推荐资源

  • 《你不知道的 JavaScript(上卷)》第二部分
  • MDN Web Docs - this 关键字
  • JavaScript.info - 对象方法与 this

7.4 验证学习成果

自测题

  1. 以下代码输出什么?为什么?
var name = "window"
var obj = {
  name: "obj",
  foo: function() {
    return function() {
      console.log(this.name)
    }
  }
}
obj.foo()()
  1. 如何让以下代码正确输出 "小吴"?
var obj = {
  name: "小吴",
  getName: function() {
    setTimeout(function() {
      console.log(this.name)
    }, 1000)
  }
}
obj.getName()
  1. 以下代码的优先级判断是否正确?
function foo() {
  console.log(this)
}
var obj = {
  foo: foo.bind("bind")
}
new obj.foo() // 输出什么?

答案与解析

  1. 输出 "window"。obj.foo() 返回一个函数,然后独立调用,应用默认绑定。
  2. 使用箭头函数:setTimeout(() => { console.log(this.name) }, 1000)
  3. 输出 foo {}。new 绑定优先级高于显式绑定。

八、写在最后

this 是 JavaScript 中最具争议的特性之一。它的灵活性带来了强大的表达能力,但也增加了理解成本。

关键心态

  • 不要死记硬背,理解背后的执行机制
  • 遇到问题时,按优先级逐一排查
  • 善用工具(TypeScript、ESLint)减少错误
  • 在现代开发中,考虑使用箭头函数或 Hooks 减少对 this 的依赖

实践建议

  • 在真实项目中刻意练习 this 的判断
  • 遇到 bug 时,先检查 this 指向是否正确
  • 代码审查时,关注 this 相关的潜在问题
  • 定期回顾本文,加深理解

掌握 this 不是终点,而是深入理解 JavaScript 执行机制的起点。接下来,我们将探讨箭头函数、手写实现 call/apply/bind,以及原型链等更深入的话题。

持续学习,保持好奇心,我们下期见!

纯函数、柯里化与函数组合:从原理到源码,构建更可维护的前端代码体系

为什么要关注纯函数和柯里化?

在日常开发中,你是否遇到过这些问题:

  • 修改一个函数后,其他看似无关的模块出现了 bug
  • 相同的输入有时返回不同的结果,导致测试用例不稳定
  • 代码复用困难,类似的逻辑在多处重复编写
  • 阅读 React、Redux、Vue3 源码时,对某些设计模式感到困惑

这些问题的根源往往在于:缺乏对函数式编程核心概念的理解。纯函数和柯里化作为函数式编程的两大基石,不仅能帮助我们写出更稳定、可测试的代码,更是理解现代前端框架设计思想的关键。

本文收益:

  • 掌握纯函数的定义与实践,避免副作用带来的隐患
  • 理解柯里化的本质,学会用单一职责原则优化代码结构
  • 从 Vue3、Redux 源码中看到这些思想的实际应用
  • 获得可直接落地的编码实践和团队推广建议

一、纯函数:稳定性的基石

1.1 什么是纯函数

JavaScript 符合函数式编程范式,纯函数是其中最重要的概念之一。在 React 开发中,组件被要求像纯函数一样工作;在 Redux 中,reducer 必须是纯函数。理解纯函数,是掌握现代前端框架的必经之路。

下图展示了 Redux 官方文档对数据不可变性的强调:

图 1:React 中的数据不可变性

根据维基百科定义,纯函数需要满足三个条件:

  1. 确定性输出:相同的输入必然产生相同的输出
  2. 无外部依赖:输出只依赖于输入参数,不依赖外部状态或 I/O 设备
  3. 无副作用:不触发事件、不修改外部状态、不改变输入参数

简单总结:

  • 确定的输入 → 确定的输出(可预测性)
  • 执行过程中不产生副作用(隔离性)

"纯"字表达的是"纯粹"的含义,即函数只做一件事:根据输入计算输出,不做任何额外操作。

1.2 副作用:bug 的温床

什么是副作用?

副作用(Side Effect)源自医学概念,指药物在治疗疾病之外产生的额外影响。在计算机科学中,副作用指函数执行时,除了返回值之外对外部环境产生的影响,例如:

  • 修改全局变量
  • 修改传入的参数对象
  • 发起网络请求
  • 操作 DOM
  • 写入文件或数据库
  • 打印日志(严格来说也是副作用,但通常可接受)

为什么副作用是问题?

副作用会破坏代码的可预测性和可测试性。当函数依赖或修改外部状态时:

  • 相同输入可能产生不同输出
  • 函数行为难以追踪和调试
  • 并发执行时可能产生竞态条件
  • 单元测试需要复杂的 mock 和环境准备

在编程中,我们提倡"数据的不可变性"(Immutability):尽量不修改原有数据,而是创建新数据。这是避免副作用的重要实践。

1.3 纯函数实战案例

让我们通过数组操作来理解纯函数:

案例 1:slice vs splice

const names = ["小吴", "why", "JS高级"];

// slice 是纯函数
// 1. 相同输入产生相同输出
// 2. 不修改原数组
const newNames1 = names.slice(0, 2);
console.log("newNames1:", newNames1); // ["小吴", "why"]
console.log("names:", names);          // ["小吴", "why", "JS高级"] - 原数组未变

// splice 不是纯函数
// 会修改原数组,产生副作用
const newNames2 = names.splice(2);
console.log("newNames2:", newNames2); // ["JS高级"]
console.log("names:", names);          // ["小吴", "why"] - 原数组被修改!

案例 2:对象操作

// ❌ 非纯函数:直接修改传入的对象
function baz(info) {
  info.age = 100; // 副作用:修改了外部对象
}

const obj = { name: "小吴", age: 23 };
baz(obj);
console.log(obj); // { name: "小吴", age: 100 } - 原对象被修改

// ✅ 纯函数:返回新对象,不修改原对象
function test(info) {
  return {
    ...info,
    age: 100
  };
}

const obj2 = { name: "小吴", age: 23 };
const newObj = test(obj2);
console.log(obj2);   // { name: "小吴", age: 23 } - 原对象未变
console.log(newObj); // { name: "小吴", age: 100 } - 新对象

案例 3:React 组件

// React 函数组件应该像纯函数一样
// ✅ 正确:不修改 props
function HelloWorld(props) {
  // 只读取 props,不修改
  return <div>{props.message}</div>;
}

// ❌ 错误:修改 props
function BadComponent(props) {
  props.count++; // 违反纯函数原则!
  return <div>{props.count}</div>;
}

1.4 纯函数的优势

为什么纯函数在函数式编程中如此重要?

  1. 编写时更专注

    • 只需实现业务逻辑,不用担心外部状态
    • 不需要关心参数来源或依赖的外部变量
  2. 使用时更安心

    • 确定输入不会被篡改
    • 确定的输入必然产生确定的输出
    • 可以安全地并发执行
  3. 测试更简单

    • 不需要复杂的 mock 和环境准备
    • 测试用例稳定可靠
  4. 易于调试和重构

    • 函数行为可预测,问题容易定位
    • 可以安全地替换或组合函数

React 官方文档明确要求:无论是函数组件还是 class 组件,都必须像纯函数一样保护 props 不被修改。

图 2:React 的严格规则

本节小结

  • 纯函数三要素:确定性输出、无外部依赖、无副作用
  • 副作用是 bug 的温床:修改外部状态会破坏可预测性
  • 数据不可变性:优先创建新数据而非修改原数据
  • 实践原则:使用 slicemapfilter 等不修改原数组的方法
  • 框架要求:React/Redux 等框架强制要求纯函数思想

二、柯里化:单一职责的艺术

2.1 柯里化的本质

柯里化(Currying)是函数式编程的另一个核心概念。它的名字来源于数学家 Haskell Curry。

维基百科定义:

  • 把接收多个参数的函数,转换成接受单一参数的函数
  • 返回接受余下参数的新函数
  • 最终返回结果

简单理解: 只传递给函数一部分参数来调用它,让它返回另一个函数处理剩余参数。

对比示例:

// 普通函数:一次性传入所有参数
function foo(m, n, x, y) {
  return m + n + x + y;
}
foo(10, 20, 30, 40); // 100

// 柯里化函数:分步传入参数
function bar(m) {
  return function(n) {
    return function(x, y) {
      return m + n + x + y;
    };
  };
}
bar(10)(20)(30, 40); // 100

这就像调节风扇档位:复杂需求可以分档次调节,每个档位的调用都基于前一档位,档位之间紧密关联且有明确顺序。

2.2 柯里化的结构演进

2.2.1 基础多参数函数

function add(x, y, z) {
  return x + y + z;
}

const result = add(10, 20, 30);
console.log(result); // 60

2.2.2 柯里化改造

// 通过闭包实现参数保存
function sum(x) {
  return function(y) {
    return function(z) {
      return x + y + z;
    };
  };
}

const result1 = sum(10)(20)(30);
console.log(result1); // 60

关键点:

  • 每个函数接收一个参数并返回新函数
  • 通过闭包访问上层函数的参数
  • 最内层函数执行最终计算

2.2.3 箭头函数简化

// 方式 1:保留 return 关键字
const sum2 = x => y => z => {
  return x + y + z;
};

// 方式 2:隐式返回(推荐)
const sum3 = x => y => z => x + y + z;

const result2 = sum3(20)(30)(40);
console.log(result2); // 90

箭头函数的链式写法大幅简化了柯里化代码,这也是现代 JavaScript 中常见的写法。

2.3 柯里化的核心价值

2.3.1 单一职责原则(SRP)

为什么需要柯里化?

在函数式编程中,我们希望:

  • 一个函数处理的问题尽可能单一
  • 不要将一大堆处理过程交给一个函数
  • 每次传入的参数在单一函数中处理
  • 处理完后在下一个函数中使用处理结果

这体现了单一职责原则(Single Responsibility Principle):一个类(或函数)应该只有一个引起它变化的原因。

对比示例:

// ❌ 所有逻辑挤在一起
function add(x, y, z) {
  x = x + 2;
  y = y * 2;
  z = z * z;
  return x + y + z;
}
console.log(add(10, 20, 30)); // 972

// ✅ 柯里化:每层处理一个职责
function sum(x) {
  x = x + 2;  // 第一层:处理 x
  return function(y) {
    y = y * 2;  // 第二层:处理 y
    return function(z) {
      z = z * z;  // 第三层:处理 z
      return x + y + z;
    };
  };
}
console.log(sum(10)(20)(30)); // 972

注意边界:

  • 单一职责不是越细越好,过度拆分会增加复杂度
  • 职责的"粒度"需要根据实际项目判断
  • 通常 2-3 层嵌套是最常见的情况

2.3.2 逻辑复用

柯里化的另一个重要优势是复用重复的参数,这和 bind 函数的思想类似。

案例 1:固定第一个参数

function foo(m, n) {
  return m + n;
}

// 传统方式:重复传入相同的第一个参数
console.log(foo(5, 1)); // 6
console.log(foo(5, 2)); // 7
console.log(foo(5, 3)); // 8
console.log(foo(5, 4)); // 9
console.log(foo(5, 5)); // 10

// ✅ 柯里化:复用第一个参数
function makeAdder(count) {
  return function(num) {
    return count + num;
  };
}

const adder5 = makeAdder(5);
console.log(adder5(1)); // 6
console.log(adder5(2)); // 7
console.log(adder5(3)); // 8
console.log(adder5(4)); // 9
console.log(adder5(5)); // 10

案例 2:日志函数优化

// ❌ 传统方式:重复传入时间和类型
function log(date, type, message) {
  console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
}

log(new Date(), "DEBUG", "查找到轮播图的bug");
log(new Date(), "DEBUG", "查询菜单的bug");
log(new Date(), "DEBUG", "查询数据的bug");

// ✅ 柯里化优化:复用时间和类型
const logCurried = date => type => message => {
  console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
};

// 复用时间
const nowLog = logCurried(new Date());
nowLog("DEBUG")("查找小吴去哪了");

// 复用时间 + 类型
const debugLog = logCurried(new Date())("DEBUG");
debugLog("查找信息1");
debugLog("查找信息2");
debugLog("查找信息3");

优势总结:

  • 减少重复代码
  • 提高函数灵活性
  • 便于创建专用工具函数

2.4 通用柯里化函数实现

2.4.1 实现思路

如何将普通函数自动转换为柯里化函数?

需求分析:

  1. 传入一个普通函数,返回柯里化版本
  2. 需要知道函数的参数个数(通过 fn.length 获取)
  3. 支持多种调用方式:fn(1,2,3)fn(1,2)(3)fn(1)(2)(3)
// 获取函数参数个数
function foo(x, y, z, q) {
  console.log(foo.length); // 4
}

2.4.2 完整实现

function hyCurrying(fn) {
  // 返回柯里化函数
  function curried(...args) {
    // 1. 参数足够时,直接执行原函数
    if (args.length >= fn.length) {
      // 使用 apply 绑定 this,避免指向问题
      return fn.apply(this, args);
    } else {
      // 2. 参数不足时,返回新函数继续收集参数
      function curried2(...args2) {
        // 递归调用 curried,拼接参数
        return curried.apply(this, args.concat(args2));
      }
      return curried2;
    }
  }
  return curried;
}

// 测试
function add1(x, y, z) {
  return x + y + z;
}

const curryAdd = hyCurrying(add1);
console.log(curryAdd(10, 20, 30));    // 60
console.log(curryAdd(10, 20)(30));    // 60
console.log(curryAdd(10)(20)(30));    // 60

实现要点:

  • fn.length:获取原函数的形参数量(上限)
  • ...args:收集用户传入的实参(不固定)
  • 参数足够时调用原函数,不足时递归返回新函数
  • 使用 apply 绑定 this,防止指向偏移
  • 使用 concat 拼接历史参数和新参数

2.5 柯里化在源码中的应用

2.5.1 Vue3 源码案例

Vue3 源码中大量使用了柯里化思想。下图展示了 createApp 的实现:

图 3:Vue3 源码中的柯里化

在源码中,柯里化的运用方式更加灵活:

图 4:Vue3 源码 createAppAPI 的柯里化运用

代码结构:

return {
  render,
  hydrate,
  createApp: createAppAPI(render, hydrate)
};

createAppAPI 返回的函数就是 createApp,通过 ES6 对象简写形式:

// 完整形式
createApp: createApp

// 简写形式
createApp

最终形成嵌套调用:

createAppAPI(render, hydrate)(rootComponent, rootProps)

这种写法进一步扩大了封装的灵活性,但也提高了抽象程度。

2.5.2 Redux 源码案例

Redux 中也有典型的柯里化应用:

图 5:Redux 柯里化调用

参考链接:redux-thunk/src/index.ts

本节小结

  • 柯里化本质:将多参数函数转换为单参数函数链
  • 核心价值:单一职责 + 逻辑复用
  • 实现关键:闭包保存参数 + 递归收集参数
  • 应用场景:工具函数封装、参数预设、延迟执行
  • 源码体现:Vue3、Redux 等框架广泛使用
  • 注意事项:避免过度嵌套(2-3 层为宜)

三、组合函数:函数的乐高积木

3.1 什么是组合函数

组合函数(Compose Function)是函数式编程中的一种使用技巧,用于将多个函数组合成一个新函数。

场景描述:

  • 需要对数据依次执行两个函数 fn1fn2
  • 每次都要手动调用两次,操作重复
  • 能否将这两个函数组合起来,自动依次调用?

基础示例:

// 乘以 2
function double(num) {
  return num * 2;
}

// 平方
function square(num) {
  return num ** 2;
}

const count = 10;
// 传统方式:嵌套调用
const result = square(double(count)); // (10 * 2) ** 2 = 400
console.log(result);

// ✅ 组合函数:将两个函数组合
function composeFn(m, n) {
  return function(count) {
    return n(m(count));
  };
}

const newFn = composeFn(double, square);
console.log(newFn(10)); // 400

核心思想:

  • 第一层函数接收需要组合的函数
  • 返回第二层函数(组合后的函数)接收数据
  • 第二层函数内部依次执行传入的函数

3.2 组合函数的优势

  1. 保持函数独立性doublesquare 各自功能独立
  2. 减少重复调用:组合一次,多次使用
  3. 提高可读性newFn(10)square(double(10)) 更清晰
  4. 灵活组合:可以调整执行顺序 n(m(count))m(n(count))

这种模式和 bind 函数类似:所有操作都在第二层函数中完成。


四、通用组合函数实现

4.1 需求分析

前面的 composeFn 只能组合两个函数,实际开发中可能需要组合更多函数。我们需要实现一个通用的组合函数:

需求:

  • 支持传入任意数量的函数
  • 验证传入的都是函数类型
  • 按顺序依次执行函数
  • 上一个函数的返回值作为下一个函数的参数

4.2 完整实现

function hyCompose(...fns) {
  const length = fns.length;

  // 1. 验证:确保传入的都是函数
  for (let i = 0; i < length; i++) {
    if (typeof fns[i] !== 'function') {
      throw new TypeError('所有参数必须是函数类型');
    }
  }

  // 2. 返回组合后的函数
  function compose(...args) {
    let index = 0;
    // 执行第一个函数,传入所有参数
    let result = length ? fns[index].apply(this, args) : args;

    // 依次执行剩余函数,每次传入上一个函数的返回值
    while (++index < length) {
      result = fns[index].call(this, result);
    }

    return result;
  }

  return compose;
}

// 测试
function double(m) {
  return m * 2;
}

function square(n) {
  return n ** 2;
}

function addTen(x) {
  return x + 10;
}

// 组合多个函数
const newFn = hyCompose(double, square, addTen);
console.log(newFn(5)); // ((5 * 2) ** 2) + 10 = 110

实现要点:

  1. 参数验证:遍历检查每个参数是否为函数
  2. 边界处理
    • 第一个函数使用 apply 接收多个参数
    • 后续函数使用 call 接收单个参数(上一个函数的返回值)
  3. this 绑定:使用 apply/call 确保 this 指向正确
  4. 执行顺序:按传入顺序依次执行(先 double,再 square,最后 addTen

4.3 执行流程图解

newFn(5)
  ↓
double(5) → 10square(10) → 100addTen(100) → 110

本节小结

  • 组合函数:将多个函数组合成一个新函数
  • 适用场景:多个函数需要依次执行,且关联性强
  • 实现关键:第一个函数接收多参数,后续函数接收单参数
  • 执行顺序:按传入顺序依次执行
  • 注意事项:需要验证参数类型,绑定 this 指向

五、实战落地建议

5.1 代码层面

纯函数实践清单:

  1. 优先使用不可变方法

    • 数组:mapfilterreducesliceconcat
    • 对象:Object.assign({},...){...obj}
    • 避免:pushsplicesort(会修改原数组)
  2. 函数设计原则

    • 输入通过参数传递,不依赖全局变量
    • 输出通过 return 返回,不修改外部状态
    • 避免在函数内部发起网络请求或操作 DOM
  3. React 组件规范

    • 函数组件不修改 props
    • 使用 useState 管理内部状态
    • 副作用统一放在 useEffect

柯里化应用场景:

  1. 工具函数封装

    // 通用请求函数
    const request = baseURL => endpoint => params => {
      return fetch(`${baseURL}${endpoint}`, params);
    };
    
    const apiRequest = request('https://api.example.com');
    const getUserInfo = apiRequest('/user');
    getUserInfo({ id: 123 });
    
  2. 事件处理优化

    // 避免在 JSX 中创建匿名函数
    const handleClick = id => event => {
      console.log('Clicked item:', id);
    };
    
    <button onClick={handleClick(item.id)}>Click</button>
    
  3. 参数预设

    const logger = level => message => {
      console.log(`[${level}] ${message}`);
    };
    
    const errorLog = logger('ERROR');
    const infoLog = logger('INFO');
    

5.2 团队推广

渐进式推广策略:

  1. 第一阶段:意识培养

    • 团队分享会讲解纯函数和柯里化概念
    • Code Review 中指出副作用问题
    • 建立最佳实践文档
  2. 第二阶段:工具支持

    • ESLint 规则:禁止修改参数(no-param-reassign
    • 引入 Immutable.js 或 Immer.js
    • 封装常用的柯里化工具函数
  3. 第三阶段:规范落地

    • 新项目强制使用纯函数
    • 老项目逐步重构
    • 建立代码质量指标

常见问题应对:

问题 解决方案
性能担忧(创建新对象) 使用 Immer.js 优化,实际性能影响很小
学习成本高 提供代码示例和最佳实践文档
历史代码改造难 新代码严格执行,老代码逐步重构
调试困难 使用 Redux DevTools 等工具

5.3 验证指标

代码质量指标:

  • 单元测试覆盖率提升(纯函数更易测试)
  • Bug 率下降(副作用减少)
  • 代码复用率提升(柯里化提高复用性)
  • Code Review 时间减少(代码更清晰)

六、总结与展望

6.1 核心要点回顾

纯函数:

  • 确定的输入产生确定的输出
  • 不产生副作用,不修改外部状态
  • 是构建可预测、可测试代码的基础
  • React、Redux 等框架的核心要求

柯里化:

  • 将多参数函数转换为单参数函数链
  • 体现单一职责原则
  • 提高代码复用性和灵活性
  • 在 Vue3、Redux 等源码中广泛应用

组合函数:

  • 将多个函数组合成新函数
  • 保持函数独立性的同时提高复用
  • 函数式编程的重要技巧

6.2 进阶方向

  1. 深入函数式编程

    • 学习 Functor、Monad 等高级概念
    • 研究 Ramda.js、Lodash/fp 等函数式库
    • 理解函数式编程在大型项目中的应用
  2. 框架源码阅读

    • Vue3 响应式系统中的纯函数应用
    • Redux 中间件的柯里化设计
    • React Hooks 的函数式思想
  3. 性能优化

    • 使用 Immer.js 优化不可变数据操作
    • 理解 React.memo 和纯组件的关系
    • 掌握函数式编程的性能优化技巧

6.3 团队落地路线图

短期(1-2 个月):

  • 团队技术分享,统一认知
  • 建立编码规范和最佳实践文档
  • 新项目试点应用

中期(3-6 个月):

  • 封装团队通用的工具函数库
  • 配置 ESLint 规则自动检查
  • Code Review 中强化纯函数要求

长期(6 个月以上):

  • 老项目逐步重构
  • 建立代码质量监控体系
  • 沉淀团队函数式编程最佳实践

附录:常见误区

  1. 误区:纯函数不能有任何副作用

    • 正解:console.log 等调试代码是可接受的副作用
    • 关键是不影响函数的核心逻辑和可预测性
  2. 误区:柯里化会降低性能

    • 正解:现代 JavaScript 引擎优化很好,性能影响微乎其微
    • 代码可维护性的提升远大于微小的性能损失
  3. 误区:所有函数都要柯里化

    • 正解:根据实际需求选择,不要过度设计
    • 参数固定且无复用需求的函数不需要柯里化
  4. 误区:纯函数不能调用其他函数

    • 正解:可以调用其他纯函数
    • 关键是整体不产生副作用

参考资源:


本文适合有一定 JavaScript 基础的前端工程师阅读。如有疑问或建议,欢迎交流讨论。

从原理到手写:彻底吃透 call / apply / bind 与 arguments 的底层逻辑

引言:为什么我们要“手写”这些 API?

在日常开发中,callapplybind 几乎每天都会用到。无论是处理 this 绑定问题、实现函数复用,还是做函数柯里化,它们都是绕不开的基础能力。

但有一个现实问题:

很多人“会用”,但说不清楚为什么这样设计,也不知道边界在哪里。

一旦进入复杂业务场景,比如高阶函数封装、事件回调丢失上下文、React 中函数绑定优化等问题,底层理解不扎实就会成为瓶颈。

本文我们做三件事:

  • 手写实现 call / apply / bind
  • 理解它们的设计哲学与差异
  • 彻底讲清楚 arguments 的本质与演进

目标不是背代码,而是形成“可迁移的工程认知”。


一、手写 call / apply / bind

1.0 先明确三个 API 的语法

func.call(thisArg, arg1, arg2, ...)
func.apply(thisArg, [argsArray])
const newFunc = func.bind(thisArg, arg1, arg2, ...)

区别非常明确:

方法 是否立即执行 参数形式 是否返回函数
call 参数列表
apply 数组
bind 参数列表

核心差异点只有两个:

  1. 是否立即执行
  2. 参数如何传递

1.1 手写 call —— 从“执行函数”开始

第一步:给所有函数添加能力

Function.prototype.hycall = function () {
  console.log("原型链调用了")
}

function foo() {
  console.log("foo函数调用了")
}

foo.hycall()

问题出现了:

只执行了 hycall,没有执行 foo 本身。

我们真正的目标是:

  • 谁调用 hycall
  • 就执行谁

关键点:

var fn = this
fn()

优化版:

Function.prototype.hycall = function () {
  var fn = this
  fn()
}

小结

  • Function.prototype 挂方法 = 所有函数都能用
  • this 指向调用 hycall 的函数
  • call 的第一能力:立即执行函数

1.2 改变 this 指向 —— 显式绑定的核心

默认调用:

fn()  // 默认绑定 → window

我们希望:

foo.hycall({ name: "小吴" })

this 指向传入对象。

关键思路:

借助“隐式绑定规则”

thisArg.fn = fn
thisArg.fn()

实现:

Function.prototype.hycall = function (thisArg) {
  var fn = this

  thisArg.fn = fn
  thisArg.fn()

  delete thisArg.fn
}

对比原生:

foo.hycall({ name: "小吴" })
foo.call({ name: "why" })

图10-1 call调用会执行函数

为什么这样能生效?

因为:

  • obj.fn() 是隐式调用
  • 隐式调用优先级 > 默认绑定
  • 所以 this 指向 obj

这就是“借鸡生蛋”的核心思想。


1.3 处理基本类型问题

问题:

foo.hycall(123)

报错,因为:

123.fn = fn // 不允许

解决方案:

thisArg = Object(thisArg)

最终优化:

Function.prototype.hycall = function (thisArg) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn
  var result = thisArg.fn()
  delete thisArg.fn

  return result
}

图10-4 转化为对象的处理方式结果

小结

  • 基本类型会被装箱
  • null / undefined 特殊处理
  • JS 实现无法做到“完全无痕绑定”

1.4 让 call 支持传参 —— ES6 剩余参数

核心能力:

foo.call(obj, a, b, c)

实现:

Function.prototype.hycall = function (thisArg, ...args) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn
  var result = thisArg.fn(...args)
  delete thisArg.fn

  return result
}

示例:

function foo(num1, num2, num3) {
  console.log(this, num1 + num2 + num3)
}

foo.hycall("小吴", 500, 20, 1)

为什么 call 必须支持参数?

因为:

  • 每次函数调用都会创建新的执行上下文
  • 改变 this 必须在“那次调用”里完成

错误方式:

foo.call("why")
foo(500,20,1) // this 失效

这是典型“刻舟求剑”。


1.5 手写 apply

区别只在参数形式。

Function.prototype.myapply = function (thisArg, argArray) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn

  argArray = argArray || []
  var result = thisArg.fn(...argArray)

  delete thisArg.fn

  return result
}

关键差异:

  • call:参数列表
  • apply:数组

小结

  • apply 更适合参数本来就是数组的场景
  • 本质逻辑与 call 一致
  • 区别只是“参数结构”

1.6 手写 bind —— 真正的升级版

bind 解决什么问题?

延迟执行 + 参数预设

示例:

function foo(num1, num2, num3, num4) {
  console.log(this, num1, num2, num3, num4)
}

三种用法:

var bar = foo.bind("小吴", 10, 20, 30, 40)
bar()

var bar = foo.bind("小吴")
bar(10, 20, 30, 40)

var bar = foo.bind("小吴", 10, 20)
bar(30, 40)

实现思路

  • 第一次调用 bind:固定 this + 默认参数
  • 返回新函数
  • 第二次执行:合并参数再执行

实现:

Function.prototype.mybind = function (thisArg, ...argArray) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  function proxyFn(...args) {
    thisArg.fn = fn

    var finalArgs = [...argArray, ...args]
    var result = thisArg.fn(...finalArgs)

    delete thisArg.fn
    return result
  }

  return proxyFn
}

工程理解

  • call:一次性执行
  • bind:函数工厂
  • bind 本质是“柯里化雏形”

二、认识 arguments

2.1 arguments 是什么?

定义:

类数组对象

特征:

  • 有 length
  • 可索引访问
  • 没有数组原型方法

示例:

function foo() {
  console.log(arguments.length)
  console.log(arguments[1])
  console.log(arguments.callee)
}

foo(10, 20, 30, 40, 50)

2.2 arguments 转数组

三种方式:

Array.prototype.slice.call(arguments)
Array.from(arguments)
[...arguments]

为什么 slice + call 能工作?

我们手写一个 slice:

Array.prototype.hyslice = function (start, end) {
  var arr = this
  start = start || 0
  end = end || arr.length

  var newArray = []

  for (var i = start; i < end; i++) {
    newArray.push(arr[i])
  }

  return newArray
}

var newArray = Array.prototype.hyslice.call(
  ["小吴", "why", "JS高级"],
  1,
  3
)

本质:

强行把 arguments 当作数组的 this


2.3 箭头函数为什么没有 arguments?

箭头函数:

  • 不绑定 this
  • 不绑定 arguments
  • 继承上层作用域

示例:

function foo() {
  var bar = () => {
    console.log(arguments)
  }

  return bar
}

var fn = foo(123)
fn()

图10-5 arguments打印结果

设计目的:

  • 保持语法简洁
  • 强化词法作用域一致性
  • 鼓励使用 ...rest

三、工程层面的思考

3.1 call / apply / bind 的真实差异

能力 call apply bind
改变 this
立即执行
返回函数
参数预设

3.2 实战建议

什么时候用 call?

  • 立即执行
  • 已知完整参数
  • 做方法借用

什么时候用 apply?

  • 参数已经是数组
  • Math.max.apply

什么时候用 bind?

  • 事件回调绑定
  • React 组件方法绑定
  • 部分参数预设

四、复盘与团队落地建议

关键结论

  1. 显式绑定本质是利用隐式调用规则
  2. bind 是对 call 的延迟封装
  3. arguments 是历史产物,优先使用 rest
  4. 所有 this 问题本质都是“调用方式问题”

团队落地建议

  1. code review 中严格检查 this 丢失问题
  2. 优先使用箭头函数 + rest
  3. 对高阶函数封装做统一规范
  4. 面试训练时必须能手写实现

理解 API 不等于掌握它。

真正的掌握,是知道:

  • 它解决什么问题
  • 为什么这样设计
  • 在复杂业务里如何避免踩坑

当你可以自己实现一遍,你就真正站在了语言机制这一层,而不只是使用层。

❌