JS异常函数之箭头函数

33次阅读

共计 10423 个字符,预计需要花费 27 分钟才能阅读完成。

来源:logrocket
作者:Maciej Cieślar
译者:前端小智

为了保证的可读性,本文采用意译而非直译。

想阅读更多优质文章请猛戳 GitHub 博客, 一年百来篇优质文章等着你!

为了回馈读者,《大迁世界》不定期举行(每个月一到三次),现金抽奖活动,保底 200,外加用户赞赏,希望你能成为大迁世界的小锦鲤,快来试试吧

在 JS 中,箭头函数可以像普通函数一样以多种方式使用。但是,它们一般用于需要匿名函数表达式,例如 回调函数

下面示例显示举例箭头函数作为回调函数,尤其是对于 map(), filter(), reduce(), sort() 等数组方法。

const scores = [1, 28, 66, 666];
const maxScore = Math.max(...scores);

scores.map(score => +(score / maxScore).toFixed(2)); 

乍一看,箭头函数似乎可以按常规函数来定义与使用,但事实并非如此。出于箭头函数的简洁性,它与常规函数有所不同,换一种看法,箭头函数也许可以把箭头函数看作是异常的 JS 函数。

虽然箭头函数的语法非常简单,但这不是本文的重点。本文主要讲讲箭头函数与常规函数行为的差异,以及咱们如果利用这些差异来更好使用箭头函数。

  • 无论在严格模式还是非严格模式下,箭头函数都不能具有重复的命名参数。
  • 箭头函数没有 arguments 绑定。但是,它们可以访问最接近的非箭头父函数的 arguments 对象。
  • 箭头函数永远不能用作构造函数,自然的不能使用 new 关键字调用它们,因此,对于箭头函数不存在 prototype 属性。
  • 在函数的整个生命周期中,箭头函数内部的值保持不变,并且总是与接近的非箭头父函数中的值绑定。

命名函数参数

JS 中的函数通常用命名参数定义。命名参数用于根据位置将参数映射到函数作用域中的局部变量。

来看看下面的函数:

function logParams (first, second, third) {console.log(first, second, third);
}

// first => 'Hello'
// second => 'World'
// third => '!!!'
logParams('Hello', 'World', '!!!'); // "Hello"  "World"  "!!!"

// first => {o: 3}
// second => [1, 2, 3]
// third => undefined
logParams({o: 3}, [1, 2, 3]); // {o: 3}  [1, 2, 3]

logParams()函数由三个命名参数定义: firstsecondthird。如果命名参数多于传递给函数的参数,则其余参数undefined

对于命名参数,JS 函数在非严格模式下表现出奇怪的行为。在非严格模式下,JS 函数允许有重复命名参数,来看看示例:

function logParams (first, second, first) {console.log(first, second);
}

// first => 'Hello'
// second => 'World'
// first => '!!!'
logParams('Hello', 'World', '!!!'); // "!!!"  "World"

// first => {o: 3}
// second => [1, 2, 3]
// first => undefined
logParams({o: 3}, [1, 2, 3]); // undefined  [1, 2, 3]

咱们可以看到,first参数重复了,因此,它被映射到传递给函数调用的第三个参数的值,覆盖了第一个参数,这不是一个让人喜欢的行为。

// 由于参数重复,严格模式会报错
function logParams (first, second, first) {
  "use strict";
  console.log(first, second);
}

箭头函数如何处理重复的参数

关于箭头函数:

与常规函数不同,无论在严格模式还是非严格模式下,箭头函数都不允许重复参数,重复的参数将引发语法错误。

// 只要你敢写成重复的参数,我就敢死给你看
const logParams = (first, second, first) => {console.log(first, second);
}
  

函数重载

函数重载是定义函数的能力,这样就可以根据不同的参数数量来调用对应的函数, JS 中可以利用绑定方式来实现这一功能。

来看个简单的重载函数,计算传入参数的平均值:

function average() {
  const length = arguments.length;

  if (length == 0) return 0;

  // 将参数转换为数组
  const numbers = Array.prototype.slice.call(arguments);

  const sumReduceFn = function (a, b) {return a + Number(b) };
  // 返回数组元素的总和除以数组的长度
  return numbers.reduce(sumReduceFn, 0) / length;
}

这样函数可以用任意数量的参数调用,从 0 到函数可以接受的最大参数数量应该是255

average(); // 0
average('3o', 4, 5); // NaN
average('1', 2, '3', 4, '5', 6, 7, 8, 9, 10); // 5.5
average(1.75, 2.25, 3.5, 4.125, 5.875); // 3.5   

现在尝试使用剪头函数语法复制 average() 函数,一般咱们会觉得,这没啥难的,无法就这样:

const average = () => {
  const length = arguments.length;

  if (length == 0) return 0;

  const numbers = Array.prototype.slice.call(arguments);
  const sumReduceFn = function (a, b) {return a + Number(b) };

  return numbers.reduce(sumReduceFn, 0) / length;
}

现在测试这个函数时,咱们会发现它会抛出一个引用错误,arguments 未定义。

咱们做错了啥

对于箭头函数:

与常规函数不同,arguments不存在于箭头函数中。但是,可以访问非箭头父函数的 arguments 对象。

基于这种理解,可以将 average() 函数修改为一个常规函数,该函数将返回立即调用的嵌套箭头函数执行的结果,该嵌套箭头函数就能够访问父函数的arguments

function average() {return (() => {
    const length = arguments.length;

    if (length == 0) return 0;

    const numbers = Array.prototype.slice.call(arguments);
    const sumReduceFn = function (a, b) {return a + Number(b) };

    return numbers.reduce(sumReduceFn, 0) / length;
  })();}

这样就可以解决了 arguments 对象没有定义的问题,但这种狗屎做法显然很多余了。

做点不一样的

对于上面问题是否存在替代方法呢,可以使用 es6 的 rest 参数。

使用 ES6 rest 参数,咱们可以得到一个数组,该数组保存了传递给该函数的所有的参数。rest语法适用于所有类型的函数,无论是常规函数还是箭头函数。

const average = (...args) => {if (args.length == 0) return 0;
  const sumReduceFn = function (a, b) {return a + Number(b) };

  return args.reduce(sumReduceFn, 0) / args.length;
}

对于使用 rest 参数需要注意一些事项:

  • rest参数与函数内部的 arguments 对象不同。rest参数是一个实际的函数参数,而 arguments 对象是一个绑定到函数作用域的内部对象。
  • 一个函数只能有一个 rest 参数,而且它必须位于最后一个参数。这意味着函数可以包含命名参数和 rest 参数的组合。
  • rest 参数与命名参数一起使用时,它不包含所有传入的参数。但是,当它是惟一的函数参数时,表示函数参数。另一方面,函数的 arguments 对象总是捕获所有函数的参数。
  • rest参数指向包含所有捕获函数参数的数组对象,而 arguments 对象指向包含所有函数参数的类数组对象。

接着考虑另一个简单的重载函数,该函数将数字根据传入的进制转换为另一个类的进制数。可以使用一到三个参数调用该函数。但是,当使用两个或更少的参数调用它时,它会交换第二个和第三个函数参数。如下所示:

function baseConvert (num, fromRadix = 10, toRadix = 10) {if (arguments.length < 3) {
    // swap variables using array destructuring
    [toRadix, fromRadix] = [fromRadix, toRadix];
  }
  return parseInt(num, fromRadix).toString(toRadix);
}

调用 baseConvert 方法:

// num => 123, fromRadix => 10, toRadix => 10
console.log(baseConvert(123)); // "123"

// num => 255, fromRadix => 10, toRadix => 2
console.log(baseConvert(255, 2)); // "11111111"

// num => 'ff', fromRadix => 16, toRadix => 8
console.log(baseConvert('ff', 16, 8)); // "377"

使用箭头函数来重写上面的方法:

const baseConvert = (num, ...args) => {
  // 解构 `args` 数组和
  // 设置 `fromRadix` 和 `toRadix` 局部变量
  let [fromRadix = 10, toRadix = 10] = args;

  if (args.length < 2) {
    // 使用数组解构交换变量
    [toRadix, fromRadix] = [fromRadix, toRadix];
  }

  return parseInt(num, fromRadix).toString(toRadix);
}

构造函数

可以使用 new 关键字调用常规 JS 函数,该函数作为类构造函数用于创建新的实例对象。

function Square (length = 10) {this.length = parseInt(length) || 10;

  this.getArea = function() {return Math.pow(this.length, 2);
  }

  this.getPerimeter = function() {return 4 * this.length;}
}

const square = new Square();

console.log(square.length); // 10
console.log(square.getArea()); // 100
console.log(square.getPerimeter()); // 40

console.log(typeof square); // "object"
console.log(square instanceof Square); // true

当使用 new 关键字调用常规 JS 函数时,将调用函数内部 [[Construct]] 方法来创建一个新的实例对象并分配内存。之后,函数体将正常执行,并将 this 映射到新创建的实例对象。最后,函数隐式地返回 this(新创建的实例对象),只是在函数定义中指定了一个不同的返回值。

此外,所有常规 JS 函数都有一个 prototype 属性。函数的 prototype 属性是一个对象,它包含函数创建的所有实例对象在用作构造函数时共享的属性和方法。

以下是对前面的 Square 函数的一个小修改,这次它从函数的原型上的方法,而不是构造函数本身。

function Square (length = 10) {this.length = parseInt(length) || 10;
}

Square.prototype.getArea = function() {return Math.pow(this.length, 2);
}

Square.prototype.getPerimeter = function() {return 4 * this.length;}

const square = new Square();

console.log(square.length); // 10
console.log(square.getArea()); // 100
console.log(square.getPerimeter()); // 40

console.log(typeof square); // "object"
console.log(square instanceof Square); // true

如下所知,一切仍然按预期工作。事实上,这里有一个小秘密:ES6 类在后台执行类似于上面代码片段的操作 – 类 (class) 只是个语法糖。

那么箭头函数呢

它们是否也与常规 JS 函数共享此行为? 答案是否定的。关于箭头函数:

与常规函数不同,箭头函数永远不能使用 new 关键字调用,因为它们没有 [[Construct]] 方法。因此,箭头函数也不存在 prototype 属性。

箭头函数不能用作构造函数,无法使用 new 关键字调用它们,如果这样做了会抛出一个错误,表明该函数不是构造函数。

因此,对于箭头函数,不存在可以作为构造函数调用的函数内部的 new.target 等绑定,相反,它们使用最接近的非箭头父函数的 new.target 值。

此外,由于无法使用 new 关键字调用箭头函数,因此实际上不需要它们具有原型。因此,箭头函数不存在 prototype 属性。

由于箭头函数的prototypeundefined,尝试使用属性和方法来扩充它,或者访问它上面的属性,都会引发错误。

const Square = (length = 10) => {this.length = parseInt(length) || 10;
}

// throws an error
const square = new Square(5);

// throws an error
Square.prototype.getArea = function() {return Math.pow(this.length, 2);
}

console.log(Square.prototype); // undefined

this 是啥

JS 函数的每次调用都与调用上下文相关联,这取决于函数是如何调用的,或者在哪里调用的。

函数内部 this 值依赖于函数在调用时的调用上下文,这通常会让开发人员不得不问自己一个问题:this值是啥。

下面是对不同类型的函数调用 this 指向一些总结:

  • 使用 new 关键字调用:this指向由函数的内部 [[Construct]] 方法创建的新实例对象。this(新创建的实例对象)通常在默认情况下返回,除了在函数定义中显式指定了不同的返回值。
  • 不使用 new 关键字直接调用 : 在非严格模式下,this 指向 window 对象 (浏览器中)。然而,在严格模式下,this 值为 undefined; 因此,试图访问或设置此属性将引发错误。
  • 间接使用绑定对象调用 Function.prototype 对象提供了三种方法,可以在调用函数时将函数绑定到任意对象,即:call()apply()bind()。使用这些方法调用函数时,this 指向指定的绑定对象。
  • 作为对象方法调用 this 指向调用函数(方法)的对象,无论该方法是被定义为对象的自己的属性还是从对象的原型链中解析。
  • 作为事件处理程序调用 : 对于用作 DOM 事件侦听器的常规函数,this 指向触发事件的目标对象、DOM 元素、documentwindow

再来看个函数,该函数将用作单击事件侦听器,例如,表单提交按钮:

function processFormData (evt) {evt.preventDefault();

  const form = this.closest('form');

  const data = new FormData(form);
  const {action: url, method} = form;
}

button.addEventListener('click', processFormData, false);

与前面看到的一样,事件侦听器函数中的 this值是触发单击事件的 DOM 元素,在本例中是button

因此,可以使用以下命令指向 submit 按钮的父表单

this.closest('form');

如果将函数更改为箭头函数语法,会发生什么?

const processFormData = (evt) => {evt.preventDefault();

  const form = this.closest('form');
  const data = new FormData(form);
  const {action: url, method} = form;
}

button.addEventListener('click', processFormData, false);

如果现在尝试此操作,咱们就得到一个错误。从表面上看,this 的值并不是各位想要的。由于某种原因,它不再指向 button 元素,而是指向 window 对象。

如何修复 this 指向

利用上面提到的 Function.prototype.bind() 强制将 this 值绑定到 button 元素:

button.addEventListener('click', processFormData.bind(button), false);

但这似乎不是各位想要的解决办法。this仍然指向 window 对象。这是箭头函数特有的问题吗? 这是否意味着箭头函数不能用于依赖于 this 的事件处理?

为什么会搞错

关于箭头函数的最后一件事:

与常规函数不同,箭头函数没有 this 的绑定。this的值将解析为最接近的非箭头父函数或全局对象的值。

这解释了为什么事件侦听器箭头函数中的 this 值指向 window 对象(全局对象)。由于它没有嵌套在父函数中,因此它使用来自最近的父作用域的this 值,该作用域是全局作用域。

但是,这并不能解释为什么不能使用 bind() 将事件侦听器箭头函数绑定到 button 元素。对此有一个解释:

与常规函数不同,内部箭头函数的 this 值保持不变,并且无论调用上下文如何,都不能在其整个生命周期中更改。

箭头函数的这种行为使得 JS 引擎可以优化它们,因为可以事先确定函数绑定。

考虑一个稍微不同的场景,其中事件处理程序是使用对象方法中的常规函数​​定义的,并且还取决于同一对象的另一个方法:

({_sortByFileSize: function (filelist) {const files = Array.from(filelist).sort(function (a, b) {return a.size - b.size;});

    return files.map(function (file) {return file.name;});
  },

  init: function (input) {input.addEventListener('change', function (evt) {
      const files = evt.target.files;
      console.log(this._sortByFileSize(files));
    }, false);
  }

}).init(document.getElementById('file-input'));

上面是一个一次性的对象,该对象带有 _sortByFileSize() 方法和 init() 方法,并立即调 init 方法。init()方法接受一个 input 元素,并为 input 元素设置一个更改事件处理程序,该事件处理程序按文件大小对上传的文件进行排序,并打印在浏览器的控制台。

如果测试这段代码,会发现,当选择要上载的文件时,文件列表不会被排序并打印到控制台; 相反,会控制台上抛出一个错误,问题就出在这一行:

console.log(this._sortByFileSize(files));

在事件监听器函数内部,this 指向 input 元素 因此this._sortByFileSizeundefined

要解决此问题,需要将事件侦听器中的 this 绑定到包含方法的外部对象,以便可以调用this._sortByFileSize()。在这里,可以使用bind(),如下所示:

init: function (input) {input.addEventListener('change', (function (evt) {
    const files = evt.target.files;
    console.log(this._sortByFileSize(files));
  }).bind(this), false);
}

现在一切正常。这里不使用 bind(),可以简单地用一个箭头函数替换事件侦听器函数。箭头函数将使用父init() 方法中的 this 的值:

init: function (input) {input.addEventListener('change', (function (evt) {
    const files = evt.target.files;
    console.log(this._sortByFileSize(files));
  }).bind(this), false);
}

再考虑一个场景,假设有一个简单的计时器函数,可以将其作为构造函数调用来创建以秒为单位的倒计时计时器。使用 setInterval() 进行倒计时,直到持续时间过期或间隔被清除为止,如下所示:

function Timer (seconds = 60) {this.seconds = parseInt(seconds) || 60;
  console.log(this.seconds);

  this.interval = setInterval(function () {console.log(--this.seconds);

    if (this.seconds == 0) {this.interval && clearInterval(this.interval);
    }
  }, 1000);
}

const timer = new Timer(30);

如果运行这段代码,会看到倒计时计时器似乎被打破了,在控制台上一直打印 NaN

这里的问题是,在传递给 setInterval() 的回调函数中,this指向全局 window 对象,而不是 Timer() 函数作用域内新创建的实例对象。因此,this.secondsthis.interval 都是 undefined 的。

与之前一样,要修复这个问题,可以使用 bind()setInterval()回调函数中的 this 值绑定到新创建的实例对象,如下所示

function Timer (seconds = 60) {this.seconds = parseInt(seconds) || 60;
  console.log(this.seconds);

  this.interval = setInterval((function () {console.log(--this.seconds);

    if (this.seconds == 0) {this.interval && clearInterval(this.interval);
    }
  }).bind(this), 1000);
}

或者,更好的方法是,可以用一个箭头函数替 换 setInterval()回调函数,这样它就可以使用最近的非箭头父函数的 this 值:

function Timer (seconds = 60) {this.seconds = parseInt(seconds) || 60;
  console.log(this.seconds);

  this.interval = setInterval(() => {console.log(--this.seconds);

    if (this.seconds == 0) {this.interval && clearInterval(this.interval);
    }
  }, 1000);
}

现在理解了箭头函数如何处理 this 关键字,还需要注意 箭头函数对于需要保留 this 值的情况并不理想 – 例如,在定义需要引用的对象方法时 使用需要引用目标对象的方法来扩展对象或扩充函数的原型。

不存在的绑定

在本文中,已经看到了一些绑定,这些绑定可以在常规 JS 函数中使用,但是不存在用于箭头函数的绑定。相反,箭头函数从最近的非箭头父函数派生此类绑定的值。

总之,下面是箭头函数中不存在绑定的列表:

  • arguments:调用时传递给函数的参数列表
  • new.target:使用 new 关键字作为构造函数调用的函数的引用
  • super: 对函数所属对象原型的引用,前提是该对象被定义为一个简洁的对象方法
  • this:对函数的调用上下文对象的引用

原文:https://s0dev0to.icopy.site/b…

代码部署后可能存在的 BUG 没法实时知道,事后为了解决这些 BUG,花了大量的时间进行 log 调试,这边顺便给大家推荐一个好用的 BUG 监控工具 Fundebug。

交流

干货系列文章汇总如下,觉得不错点个 Star,欢迎 加群 互相学习。

https://github.com/qq44924588…

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复 福利,即可看到福利,你懂的。

正文完
 0