普通视图
别再踩坑!JavaScript的this关键字,一次性讲透其“变脸”真相
this 关键字是JavaScript 中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。但是即使是非常有经验的JavaScript开发者也很难说清它到底指向什么。
节选自《你不知道的JavaScript》
之所以this会成为JavaScript开发者的“拦路虎”,核心在于它的指向并非固定不变,而是取决于函数的调用方式而非定义位置。但在抱怨其复杂之前,我们首先要弄明白一个关键问题: JavaScript为什么需要this?
一、JavaScript为什么需要this?
若没有this关键字,JavaScript在处理对象与函数的关联时会陷入繁琐与僵化的困境。其核心价值主要体现在两个方面:
首先是简化代码,提升复用性。假设我们定义了一个描述用户的对象,需要一个方法打印用户信息。若没有this,就必须通过对象名手动引用属性,如:
const user = {
name: "张三",
age: 25,
printInfo: function() {
console.log("姓名:" + user.name + ",年龄:" + user.age);
}
};
user.printInfo(); // 姓名:张三,年龄:25
这种写法看似可行,但当对象名发生改变(如重构时将user改为person),printInfo方法内部的引用也必须同步修改。 若要创建多个用户实例,每个实例的printInfo方法都需要重新编写,无法复用。
this的出现彻底解决了这个问题———它能自动指向调用方法的对象,让函数摆脱对具体对象名的依赖:
const user1 = {
name: "张三",
age: 25,
printInfo: function() {
console.log("姓名:" + this.name + ",年龄:" + this.age);
}
};
const user2 = {
name: "李四",
age: 30,
printInfo: user1.printInfo // 复用同一方法
};
user1.printInfo(); // 姓名:张三,年龄:25
user2.printInfo(); // 姓名:李四,年龄:30
明确上下文关联:在面向对象编程中,函数(方法)需要知道自己属于哪个对象实例,才能正确操作实例的属性和方法。
this就像一个“动态指针”,在函数调用时自动绑定到当前上下文对象,让方法能精准访问所属实例的资源。例如在构造函数中,this直接指向新创建的实例,确保每个实例都能拥有独立的属性:
function User(name, age) {
this.name = name; // this指向新创建的User实例
this.age = age;
this.printInfo = function() {
console.log("姓名:" + this.name + ",年龄:" + this.age);
};
}
const user1 = new User("张三", 25);
const user2 = new User("李四", 30);
user1.printInfo(); // 姓名:张三,年龄:25
二、this与作用域:容易混淆的“邻居”
很多开发者会将this与作用域混为一谈,实则二者是完全不同的概念。作用域解决的是“变量在哪里可以被访问”的问题,它在函数定义时就已确定,是静态的;而this解决的是“函数调用时指向哪个对象”的问题,它在函数调用时才确定,是动态的。
我们可以通过一个例子清晰区分二者:
const name = "全局姓名";
function print() {
const name = "函数内姓名";
console.log("作用域中的name:" + name); // 访问当前作用域的变量
console.log("this指向的name:" + this.name); // 访问this指向对象的name
}
const obj = {
name: "对象姓名",
print: print
};
// 1. 全局调用
print();
// 作用域中的name:函数内姓名(作用域静态确定,访问函数内变量)
// this指向的name:全局姓名(this动态绑定到全局对象)
// 2. 对象调用
obj.print();
// 作用域中的name:函数内姓名(作用域未变)
// this指向的name:对象姓名(this动态绑定到obj)
从结果可见,无论函数如何调用,作用域始终由定义位置决定;而this的指向则随着调用方式的变化而改变。这正是二者最核心的区别。
三、this的指向规则:从调用方式看本质
既然this的指向由调用方式决定,那么我们只需掌握不同调用场景下的绑定规则,就能精准判断this的指向。以下是四种核心规则,优先级从高到低排列:
new绑定:指向新创建的实例
当函数通过new关键字调用时,JavaScript会执行以下步骤:
- 创建一个新对象
- 将新对象的原型指向函数的原型
- 将this绑定到新对象
- 执行函数体
若函数无返回值则返回新对象。此时this必然指向新创建的实例。
function Person(name) {
this.name = name; // this指向new创建的Person实例
}
const person = new Person("张三");
console.log(person.name); // 张三
console.log(this.name); // 全局姓名(此处this为全局对象,与函数内this无关)
显式绑定:指向手动指定的对象
JavaScript函数的原型上提供了call、apply、bind三个方法,允许我们手动指定函数调用时this的指向。其中call和apply会立即执行函数,bind则返回一个绑定了this的新函数,三者的核心作用都是“显式绑定this”。
const obj1 = { name: "obj1" };
const obj2 = { name: "obj2" };
function printName() {
console.log(this.name);
}
// call绑定:第一个参数为this指向的对象,后续为函数参数
printName.call(obj1); // obj1
// apply绑定:第一个参数为this指向的对象,第二个参数为参数数组
printName.apply(obj2); // obj2
// bind绑定:返回绑定this的新函数,需手动调用
const bindFunc = printName.bind(obj1);
bindFunc(); // obj1
隐式绑定:指向调用方法的对象
当函数作为对象的方法被调用时,this会隐式绑定到调用该方法的对象。简单来说,“谁调用,this就指向谁”。
const obj = {
name: "张三",
print: function() {
console.log(this.name);
},
child: {
name: "李四",
print: function() {
console.log(this.name);
}
}
};
obj.print(); // 张三(obj调用print,this指向obj)
obj.child.print(); // 李四(child调用print,this指向child)
默认绑定:指向全局对象或undefined
当函数既不通过new调用,也不通过call/apply/bind显式绑定,更不是作为对象方法隐式绑定,而是以普通函数形式调用时,就会触发默认绑定。此时在非严格模式下,this指向全局对象(浏览器中为window,Node.js中为global);在严格模式下,this指向undefined,这是为了避免意外修改全局对象。
// 非严格模式
const name = "全局";
function print() {
console.log(this.name);
}
print(); // 全局(this指向window)
// 严格模式
function strictPrint() {
"use strict";
console.log(this); // undefined
}
strictPrint();
四、特殊场景:打破规则的“例外”
除了上述四种核心规则,还有两种特殊场景会改变this的指向逻辑,需要特别注意:
箭头函数:无独立this,继承上下文this
ES6引入的箭头函数并不遵循上述任何规则,它没有自己的this绑定。箭头函数会捕获其定义时所在上下文的this,并将其作为自己的this,且这个绑定是永久的,无法通过call、apply、bind修改,也不能作为构造函数使用new。
const obj = {
name: "obj",
print: function() {
// 箭头函数继承print方法的this(即obj)
const arrowFunc = () => console.log(this.name);
arrowFunc();
}
};
obj.print(); // obj
// 尝试修改箭头函数this
const arrowFunc = () => console.log(this.name);
arrowFunc.call({ name: "newObj" }); // 全局(非严格模式下,箭头函数继承全局this)
事件处理函数:指向触发事件的元素
在浏览器的DOM事件处理中,当函数作为事件监听器被调用时,this会自动指向触发该事件的DOM元素。但如果使用箭头函数作为事件处理函数,由于其this继承自定义时的上下文,就会失去这种默认绑定。
// HTML:<button id="btn">点击</button>
const btn = document.getElementById("btn");
// 普通函数作为事件处理函数
btn.addEventListener("click", function() {
console.log(this); // <button id="btn">点击</button>(指向触发元素)
});
// 箭头函数作为事件处理函数
btn.addEventListener("click", () => {
console.log(this); // window(继承全局上下文this)
});
五、总结:判断this指向的核心步骤
面对复杂的this指向问题,我们可以按照以下步骤逐步判断,几乎能覆盖所有场景:
- 判断函数是否通过new调用?若是,this指向新创建的实例;
- 判断函数是否通过call、apply、bind显式绑定?若是,this指向手动指定的对象;
- 判断函数是否作为对象方法隐式绑定?若是,this指向调用方法的对象;
- 判断函数是否为箭头函数?若是,this继承定义时上下文的this;
- 若以上都不是,触发默认绑定:非严格模式指向全局对象,严格模式指向undefined。
一次搞懂柯里化:从最简单代码到支持任意函数,这篇让你不再踩参数传递的坑
在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,尽管它是 Moses Schnfinkel 和 Gottlob Frege 发明的。
如果读者还不了解柯里化,推荐阅读(写多参数函数总重复传值?用柯里化3步搞定 - 掘金)
一、为什么要做柯里化
柯里化是函数式编程中优化参数传递的重要技巧,核心价值在于提升函数的灵活性与复用性。
它能将多参数函数拆解为多次传参的过程,实现参数复用—— 固定通用参数(如接口基础地址),后续调用只需传递变化部分,减少重复代码。
支持延迟执行,分步收集参数(如表单分步骤填写后校验),最后统一执行。结合占位符还能灵活调整传参顺序,应对复杂场景。
本文将带读者从通用柯里化函数=>支持占位符=>支持剩余参数理解柯里化函数
二、柯里化函数的实现
2.1 通用柯里化函数
通用柯里化函数的实现的思路是收集传入参数的长度,当收集参数长度大于等于(>=)原函数的参数长度时,调用原函数。
function curry(fn, ...args) {//传入一个函数,并收集参数
const requiredArgs = fn.length;//函数的参数长度
return function(...newArgs) {//返回一个函数体,函数体收集参数
//注意:这里形成了一个闭包保存上次收集的参数数组:args:Array[]
const allArgs = [...args, ...newArgs]; //解构两个数组并合并 形成新数组
if (allArgs.length >= requiredArgs) {//判断收集参数是否足够
return fn.apply(this, allArgs);// 足够执行原函数
} else {
return curry.call(this, fn, ...allArgs);//否则再次调用柯里化函数 返回一个新函数
}
};
}
测试代码以及结果:
function sum(a, b, c) {
return a + b + c;
}
const curriedSum = curry(sum);
console.log(curriedSum(1)(2)(3)); // 6
console.log(curriedSum(1, 2)(3)); // 6
console.log(curriedSum(1)(2, 3)); // 6
console.log(curriedSum(1, 2, 3)); // 6
// 参数复用示例
function greet(greeting, name) {
return `${greeting}, ${name}!`;
}
const sayHello = curry(greet, 'Hello');
console.log(sayHello('Alice')); // "Hello, Alice!"
console.log(sayHello('Bob')); // "Hello, Bob!"
// 处理对象参数的函数
function getUserInfo(name, age, address) {
return `Name: ${name}, Age: ${age}, Address: ${address}`;
}
const getBeijingUser = curry(getUserInfo, undefined, undefined, 'Beijing');
console.log(getBeijingUser('张三', 30)); // "Name: 张三, Age: 30, Address: Beijing"
优点:代码简单,易于理解,代码薄弱的同学更好理解。
缺点:
- 对特殊函数参数支持不足
- 不支持参数灵活传递
- this 指向可能不符合预期
- 不支持动态参数长度场景
2.2 支持占位符的柯里化函数
支持占位符的柯里化函数,核心思想是维护一个参数数组的前 fn.length 个参数,还未填入的参数,调用时将会用undefined 暂时替代,填入新参数更新参数数据,当前 fn.length 的参数中不存在 undefined 时调用原函数
function curry(fn, ...args) {
const requiredArgs = fn.length;
// 辅助函数:检查是否所有参数都已填充(无占位符)
const isComplete = (args) => {
return args.length >= requiredArgs &&
args.slice(0, requiredArgs).every(arg => arg !== undefined);
//Array.every 当所有返回值都为true 返回ture
};
// 辅助函数:合并新旧参数,替换占位符
const mergeArgs = (oldArgs, newArgs) => {
const merged = [...oldArgs];
let newArgIndex = 0;//遍历新参数的索引
// 先替换已有占位符
for (let i = 0; i < merged.length && newArgIndex < newArgs.length; i++) {
//遍历现有的参数,将参数中的undefined优先替代
// 举例:merged: [undefined,undefined,1] new:["manba",undefined,"Heaven"]
//=>merged: ["manba",undefined,1]
if (merged[i] === undefined) {
merged[i] = newArgs[newArgIndex++];//注意:第二次传入的参数也可以为undefined
}
}
// 补充剩余的新参数
while (newArgIndex < newArgs.length) {
merged.push(newArgs[newArgIndex++]);
}
return merged; //merged:["manba",undefined,1,"Heaven"]
};
// 如果参数已完整(无占位符),执行原函数
if (isComplete(args)) {
return fn.apply(this, args.slice(0, requiredArgs));
}
// 否则返回新函数继续收集参数
return function (...newArgs) {
const mergedArgs = mergeArgs(args, newArgs);//调用函数 mergeArgs
return curry.call(this, fn, ...mergedArgs);
};
}
测试代码笔者选用了上一篇文章中的使用场景的例子,结合上一篇的理解,可以帮助读者更好的理解柯里化,测试代码及其结果:
// 应用场景:多参数费用计算
function calculateTotal(
productCost, // 商品费
packagingFee, // 打包费
shippingFee, // 运费
tax, // 纳税
tariff, // 关税
redEnvelope, // 红包
coupon // 代金券
) {
return productCost + packagingFee + shippingFee + tax + tariff - redEnvelope - coupon;
}
// 1. 柯里化费用计算函数
const curriedCalculate = curry(calculateTotal);
// 2. 第一步:用占位符固定后5个参数(前2个参数留空)
const fixedCommonCosts = curriedCalculate(undefined, undefined, 5, 0, 0, 0, 0);
// 此时参数为:[_, _, 5, 0, 0, 0, 0](含占位符,不执行原函数)
// 3. 第二步:传入前2个参数(替换占位符)
const boxedMeal = fixedCommonCosts(35, 1);
console.log(`盒装餐品总价:${boxedMeal}元`); // 35+1+5=41元
const baggedGoods = fixedCommonCosts(20, 0);
console.log(`袋装商品总价:${baggedGoods}元`); // 20+0+5=25元
// 4. 支持部分替换和多轮替换
const partialReplace = curriedCalculate(undefined, 1, 5, undefined, 0, 0, 0); // 部分占位
const withTax = partialReplace(35, 2); // 替换剩余占位符
console.log(`含税费总价:${withTax}元`); // 35+1+5+2=43元
// 5. 支持超额参数(自动忽略多余部分)
const extraArgs = fixedCommonCosts(100, 1, '这是多余的参数');
console.log(`超额参数处理:${extraArgs}元`); // 100+1+5=106元
优点:
- 支持参数灵活传递
- 支持非顺序传参
缺点:
- 特殊参数处理缺陷:比如原函数采用"..."收集参数
- 可以用 "_" 替代 undefined 更为直观
-
this 指向问题:虽然通过
call(this)
和apply(this)
传递上下文,但如果原函数是箭头函数(无this
)或柯里化后的函数被动态改变this
(如通过bind
),可能仍存在语义不一致。
2.3 支持剩余参数(收集符...)的柯里化函数
支持剩余参数( ... )的核心思想就是,先判断函数 fn 是否中是否存在 "..." 符号,存在该符号则允许直接传空参数调用函数,或者使用提供的 "run" 关键词运行原函数,否则继续收集参数返回函数体
需要一些前端基础才能更好的理解
const _ = Symbol('placeholder');
function curry(fn, ...args) {
// 检测函数类型:是否含剩余参数(...args)
const hasRestParams = () => {
const fnStr = fn.toString().replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '');
//移除注释代码的 正则运算 因为注释代码中可能有"..."
return fnStr.includes('...');
};
// 工具函数:过滤占位符,获取有效参数
const getValidArgs = (argList) => argList.filter(arg => arg !== _);
// 工具函数:合并新旧参数,替换占位符
const mergeArgs = (oldArgs, newArgs) => {
const merged = [...oldArgs];
let newIdx = 0;
// 先替换旧参数中的占位符
for (let i = 0; i < merged.length && newIdx < newArgs.length; i++) {
if (merged[i] === _) merged[i] = newArgs[newIdx++];
}
// 补充剩余新参数
while (newIdx < newArgs.length) merged.push(newArgs[newIdx++]);
return merged;
};
// 核心:判断是否执行原函数
const isRestFn = hasRestParams();
const requiredArgs = fn.length;
const validArgs = getValidArgs(args);
// - 普通函数:有效参数数 >= 形参长度 → 执行
// - 剩余参数函数:两种触发方式:显式传空参() || 主动调用.run()方法
const shouldExecute = !isRestFn && validArgs.length >= requiredArgs;
// 执行原函数的统一方法(供内部判断和外部主动调用)
const execute = () => fn.apply(this, validArgs);
// 普通函数满足条件直接执行;剩余参数函数返回带.run()的柯里化函数
if (shouldExecute) return execute();
// 返回柯里化函数(带.run()方法,支持主动触发执行)
const curriedFn = function (...newArgs) {
// 若传空参,且是剩余参数函数 => 执行原函数
if (isRestFn && newArgs.length === 0) return execute();
// 否则继续合并参数
const mergedArgs = mergeArgs(args, newArgs);
return curry.call(this, fn, ...mergedArgs);
};
// 给剩余参数函数的柯里化结果加.run()方法(主动执行入口)
if (isRestFn) curriedFn.run = execute;
return curriedFn;
}
这里笔者就用简单的加减法来测试该代码
const sumRest = (start, ...args) => args.reduce((a, b) => a + b, 0) - start;
const curriedSumRest = curry(sumRest);
// 方式1:多次传参 + 空参触发执行
const result1 = curriedSumRest(1)(1)(2)(3)();
console.log(result1); // 1+2+3-1=5
// 方式2:多次传参 + .run()主动执行
const result2 = curriedSumRest(_, 2)(1)(5).run();
console.log(result2); // 2+5-1=6
优点:
- 支持灵活的传参
- 支持(...)收集符,能支持更多种类的函数
- 用 " _ " 替代 "undefined" 更加直观
- 可以用 ".run()" 运行函数减少阅读负担
- 支持非顺序传参
缺点:
- 闭包会带来大量性能开销
- this 指向问题依旧存在
三、总结
本文通过三个版本的柯里化实现(基础版、支持占位符版、支持剩余参数版),展现了从简单到复杂的功能演进,核心是逐步收集参数并在满足条件时执行原函数。
笔者还想写使用场景,但大脑使用过度X_X