手写call:深入理解JavaScript中的this绑定艺术

193 阅读5分钟

大家好,我是你们的技术小伙伴FogLetter。今天我们要来聊聊JavaScript中一个非常基础但又极其重要的概念——call方法。作为函数原型上的三大方法之一(call、apply、bind),call在我们日常开发中扮演着至关重要的角色。但你真的理解它的工作原理吗?让我们一起来手写实现一个myCall,彻底掌握这个方法的精髓!

为什么需要call?

在开始手写之前,我们先思考一个问题:为什么JavaScript需要call方法?

想象这样一个场景:你有一个函数gretting,它通过this.name来访问名字属性。但问题是,这个函数定义在全局作用域,而你想要在不同的对象上下文中使用它。

function gretting() {
    return `hello,I am ${this.name}`;
}

const letter = {
    name: 'letter'
}

console.log(gretting()); // "hello,I am fog"(非严格模式)或 undefined(严格模式)
console.log(gretting.call(letter)); // "hello,I am letter"

call方法的神奇之处就在于它能够让我们手动指定函数内部的this指向,而不需要将函数作为对象的方法来调用。

call的基本用法

让我们先回顾一下call的基本语法:

func.call(thisArg, arg1, arg2, ...)
  • thisArg:函数运行时指定的this值
  • arg1, arg2, ...:函数调用时传入的参数列表

apply的区别在于参数传递方式:

  • call接受参数列表
  • apply接受参数数组

手写实现myCall

现在,让我们一步步实现自己的myCall方法。我们将把这个方法添加到Function.prototype上,这样所有函数都可以调用它。

第一步:基本框架

Function.prototype.myCall = function(context, ...args) {
    // 实现代码
}

这里我们使用了ES6的rest参数语法...args来收集所有传入的参数。

第二步:处理context

contextnullundefined时,在非严格模式下应该指向全局对象(浏览器中是window),在严格模式下则保持为nullundefined

if (context === null || context === undefined) {
    context = window; // 非严格模式
    // 严格模式下可以保持原样
}

第三步:确保调用者是函数

if (typeof this !== 'function') {
    throw new TypeError('type error');
}

第四步:关键步骤 - this绑定

这是最核心的部分。我们需要将当前函数(this)作为context的一个方法调用,这样函数内部的this就会指向context

但是直接给context添加方法可能会覆盖原有的属性,所以我们需要一个唯一的键:

const fnKey = Symbol('fn'); // 使用Symbol确保唯一性
context[fnKey] = this; // 将当前函数绑定到context上

第五步:执行函数并获取结果

const result = context[fnKey](...args); // 展开参数

第六步:清理工作

delete context[fnKey]; // 删除临时添加的方法
return result; // 返回函数执行结果

完整实现

将以上步骤组合起来,我们得到完整的myCall实现:

Function.prototype.myCall = function(context, ...args) {
    if (context === null || context === undefined) {
        context = window;
    }
    
    if (typeof this !== 'function') {
        throw new TypeError('type error');
    }
    
    const fnKey = Symbol('fn');
    context[fnKey] = this;
    const result = context[fnKey](...args);
    delete context[fnKey];
    return result;
}

测试我们的myCall

让我们用之前的例子来测试:

function gretting(...args) {
    console.log(args);
    return `hello,I am ${this.name}`;
}

const letter = {
    name: 'letter'
}

console.log(gretting.myCall(letter, 1, 2, 3)); 
// 输出: [1, 2, 3]
// "hello,I am letter"

完美运行!

深入理解关键点

1. Symbol的重要性

我们使用Symbol来创建临时属性名,这是为了避免覆盖context上可能存在的同名属性。Symbol是ES6引入的原始数据类型,每个Symbol()返回的值都是唯一的。

const obj = { name: 'obj' };
obj.fn = function() { console.log('original fn') };

// 如果不使用Symbol,可能会覆盖原有方法
const fnKey = 'fn'; // 普通字符串作为键
context[fnKey] = this; // 会覆盖原有的fn方法

2. 参数处理

我们使用rest参数...args来收集所有传入的参数,然后在调用时使用展开运算符...args将它们展开。这保证了参数能够正确传递。

3. this的绑定机制

关键在于将函数临时添加为context的方法,然后通过context.method()的方式调用。这种调用方式会自动将函数内部的this绑定到context上。

严格模式下的考虑

在严格模式下,call方法的第一个参数如果是nullundefined,函数内部的this将保持为这些值,而不是默认转换为全局对象。

"use strict";
function test() {
    console.log(this);
}

test.call(null); // null
test.call(undefined); // undefined
test.call(); // undefined

而在非严格模式下:

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

test.call(null); // window
test.call(undefined); // window
test.call(); // window

在我们的实现中,可以添加严格模式的判断:

Function.prototype.myCall = function(context, ...args) {
    const isStrict = (function() { return !this; })();
    
    if (context === null || context === undefined) {
        context = isStrict ? context : window;
    }
    // 其余代码...
}

与apply和bind的区别

call vs apply

  • 相同点:都是立即执行函数并改变this指向
  • 不同点:参数传递方式不同
    • call:参数列表
    • apply:参数数组
func.call(obj, 1, 2, 3);
func.apply(obj, [1, 2, 3]);

call vs bind

  • call:立即执行,临时改变this
  • bind:返回一个新函数,永久绑定this
// call立即执行
gretting.call(letter);

// bind返回绑定后的函数
const boundFn = gretting.bind(letter);
boundFn(); // 稍后调用

实际应用场景

1. 借用方法

// 借用数组的slice方法将类数组转为真正数组
function toArray(arrayLike) {
    return Array.prototype.slice.call(arrayLike);
}

// 使用我们的myCall
function toArray(arrayLike) {
    return Array.prototype.slice.myCall(arrayLike);
}

2. 继承实现

在ES5的继承中,call用于调用父类构造函数:

function Parent(name) {
    this.name = name;
}

function Child(name, age) {
    Parent.call(this, name); // 调用父类构造函数
    this.age = age;
}

3. 性能优化

在某些性能敏感的场景,使用call可以避免不必要的中间变量:

// 更高效的Math.max应用
const max = Math.max.call(null, ...array);

边界情况处理

一个健壮的myCall实现还需要考虑更多边界情况:

  1. 原始值作为context:当传入数字、字符串等原始值时,应该被转换为对应的包装对象
function test() {
    console.log(this);
}

test.call(1); // Number {1}
test.call('str'); // String {'str'}

可以在实现中添加:

if (typeof context !== 'object') {
    context = Object(context);
}
  1. 函数没有返回值:确保正确处理函数没有返回值的情况

  2. Symbol属性不可枚举:我们添加的临时Symbol属性应该是不可枚举的

Object.defineProperty(context, fnKey, {
    value: this,
    configurable: true
});

性能优化

在实际实现中,我们可以做一些微优化:

  1. 避免每次调用都创建新的Symbol,可以缓存一个Symbol:
const callSymbol = Symbol('call');
Function.prototype.myCall = function(context, ...args) {
    // ...
    const fnKey = callSymbol;
    // ...
}
  1. 对于高频调用的函数,直接使用原生call可能更高效

ES6箭头函数的特殊情况

需要注意的是,箭头函数没有自己的this,所以callapplybind对它们无效:

const arrowFn = () => console.log(this);
arrowFn.call({name: 'obj'}); // 仍然指向定义时的this

总结

通过手写实现call方法,我们深入理解了JavaScript中this绑定的机制。关键点包括:

  1. call通过将函数作为context的方法调用来实现this绑定
  2. 使用Symbol可以避免属性冲突
  3. 参数处理需要注意收集和展开
  4. 严格模式和非严格模式下的不同行为
  5. 各种边界情况的处理

手写这些基础方法不仅能帮助我们更好地理解JavaScript的核心概念,还能在面试中展现我们的深度。更重要的是,这种理解能够让我们在实际开发中更加自信地使用这些方法。

最后,记住JavaScript的函数方法三剑客:

  • call:立即调用,参数列表
  • apply:立即调用,参数数组
  • bind:延迟调用,返回绑定函数

希望这篇笔记对你有所帮助!如果有任何问题或想法,欢迎在评论区留言讨论。

思考题:如何实现一个同时兼容call和apply功能的方法?欢迎在评论区分享你的解决方案!