阅读视图

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

别再踩坑!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会执行以下步骤:

  1. 创建一个新对象
  2. 将新对象的原型指向函数的原型
  3. 将this绑定到新对象
  4. 执行函数体

若函数无返回值则返回新对象。此时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指向问题,我们可以按照以下步骤逐步判断,几乎能覆盖所有场景:

  1. 判断函数是否通过new调用?若是,this指向新创建的实例;
  2. 判断函数是否通过call、apply、bind显式绑定?若是,this指向手动指定的对象;
  3. 判断函数是否作为对象方法隐式绑定?若是,this指向调用方法的对象;
  4. 判断函数是否为箭头函数?若是,this继承定义时上下文的this;
  5. 若以上都不是,触发默认绑定:非严格模式指向全局对象,严格模式指向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

❌