返回

TS Array.map `thisArg`作计数器?深度解析与方案

javascript

TypeScript 中 Array.map() 的 this 参数能当计数器用吗?解密与妙用

在使用 TypeScript 开发时,我们经常会用到 Array.map() 方法来转换数组。一个有趣的问题是:map 方法的第二个参数 thisArg,能不能被用来在遍历过程中充当一个自增计数器?

直接上代码,看看最初的想法是什么样的:

// 假设我们已经有了 insertedPlaylistIdResponse 和 insertedSongIdsResponse
// insertedPlaylistIdResponse = { id: 1 }; // 示例数据
// insertedSongIdsResponse = [{ id: 101 }, { id: 102 }, { id: 103 }]; // 示例数据

var position = 0; // 期望这个 position 作为计数器的初始值
if (insertedPlaylistIdResponse?.id) {
  const playlistItems = insertedSongIdsResponse.map((songIdResponse) => {
    // 重点来了:试图在箭头函数回调里使用 this
    if (this !== undefined) { // 这里的 this 是什么?
      return undefined; // 期望的逻辑分支其实是 else
    } else {
      const newPosition = this + 1; // 期望 this 是 position,并且能自增
      return {
        playlist_id: insertedPlaylistIdResponse.id,
        song_id: songIdResponse.id,
        position: newPosition,
      };
    }
  }, position); // 将 position 作为 thisArg 传入
  // ...后续数据库操作
}

这段代码的核心意图是,在 map 遍历 insertedSongIdsResponse 时,为每一项生成一个 playlist_item 对象,并且这个对象的 position 属性能够从 1 开始依次递增。尝试者希望利用 map 函数的 thisArg 参数,将外部的 position变量传入,并在每次迭代时让 this 自动引用并累加这个 position

结果呢?TypeScript 编译器可能会提示 this 的类型问题,或者在运行时发现 this 并非我们期望的那个可累加的数字。为什么呢?

一、为什么这种方式行不通?

咱们得从 JavaScript (以及 TypeScript) 中 this 的工作机制和 Array.map() 的特性说起。

1. 箭头函数中的 this

这是最主要的原因。在上述代码中,map 的回调函数是一个箭头函数

(songIdResponse) => {
  // ...
  const newPosition = this + 1; //这里的 this
  // ...
}

箭头函数有一个非常关键的特性:它不绑定自己的 this 。箭头函数中的 this 会捕获其词法上下文,也就是说,它会继承外层作用域的 this 值。

在你提供的代码片段中,如果这段代码位于一个类的成员方法中,箭头函数内的 this 就会指向该类的实例。如果它位于全局作用域或者一个普通函数中(且该普通函数没有被特定对象调用),this 在严格模式下可能是 undefined,在非严格模式下可能是全局对象(浏览器中的 window,Node.js 中的 global)。

重点在于,无论你给 mapthisArg 传入什么,箭头函数回调中的 this 始终是由其定义时所在的词法环境决定的,thisArg 对它无效。

所以,if (this !== undefined) 这个判断,以及后续的 this + 1,其操作的 this 根本不是你传入的 position 变量。

2. thisArg 与基本类型

退一步讲,即使我们不用箭头函数,改用传统的 function 定义的函数,尝试将基本类型(如数字 0)作为 thisArg 来实现计数器,也会遇到麻烦。

mapthisArg 会成为回调函数执行时的 this 上下文。如果传入的是一个基本类型值(比如数字 0),在非严格模式下,这个基本类型值会被包装成其对应的对象(new Number(0))。在严格模式下 ("use strict"),this 会是你传入的原始值,如果是 nullundefinedthis 就是 undefined

关键问题是,在回调函数内部,即使 this 确实是 Number(0) 这个对象(或者原始数字 0),你执行 this + 1 得到的是一个新的数字。你无法通过修改 this 本身(例如 this = this + 1 是非法的)来影响下一次迭代时回调函数看到的 this 值。thisArgmap 的每次回调调用时,都是重新 将最初传入的那个值(或其包装对象)设置为 this 上下文。它不是一个持久的、可以跨迭代修改的引用。

简单说,thisArg 是让你指定一个固定的上下文对象 ,而不是一个可供迭代修改的“状态载体”。

二、可行的解决方案

既然直接用 thisArg 当计数器此路不通,我们有哪些地道的方式来实现这个需求呢?

方案一:巧用 map 自带的 index 参数

这通常是最简单直接的方法。Array.map() 的回调函数可以接收三个参数:当前元素 element、当前索引 index 和被遍历的数组本身 array

我们可以利用 index 参数来生成从 1 开始的 position

  • 原理与作用:
    map 方法在遍历数组时,会为每个元素调用一次回调函数,并自动传入当前元素的索引。这个索引是从 0 开始的。我们只需要将这个索引加 1,就能得到我们想要的 position

  • 代码示例:

    if (insertedPlaylistIdResponse?.id) {
      const playlistItems = insertedSongIdsResponse.map((songIdResponse, index) => {
        // index 是从 0 开始的,所以 position 是 index + 1
        const newPosition = index + 1;
        return {
          playlist_id: insertedPlaylistIdResponse.id,
          song_id: songIdResponse.id, // 假设 songIdResponse 直接就是 id 或者包含 id
          position: newPosition,
        };
      });
    
      // 假设 db.insertInto('playlist_item').values(playlistItems).execute() 能够处理这样的数组
      // await db
      //   .insertInto('playlist_item')
      //   .values(playlistItems)
      //   .execute();
      console.log(playlistItems); // 看看结果
    }
    
    // 示例数据,使代码可运行
    const insertedPlaylistIdResponse = { id: 1 };
    const insertedSongIdsResponse = [{ id: 101 }, { id: 102 }, { id: 103 }];
    
    // 执行测试
    if (insertedPlaylistIdResponse?.id) {
      const playlistItems = insertedSongIdsResponse.map((songIdResponse, index) => {
        const newPosition = index + 1;
        return {
          playlist_id: insertedPlaylistIdResponse.id,
          song_id: songIdResponse.id,
          position: newPosition,
        };
      });
      console.log('使用 index 参数:', playlistItems);
      /*
      输出:
      使用 index 参数: [
        { playlist_id: 1, song_id: 101, position: 1 },
        { playlist_id: 1, song_id: 102, position: 2 },
        { playlist_id: 1, song_id: 103, position: 3 }
      ]
      */
    }
    
  • 额外建议:

    • 这是最符合 map 设计理念的用法,清晰、简洁,没有副作用。
    • 如果你的 songIdResponse 结构体是 { id: number } 这样的,记得访问 songIdResponse.id

方案二:利用闭包维护外部计数器

如果你不想用 index(虽然在这里它是最佳选择),或者你的计数逻辑更复杂,可以借助闭包。

  • 原理与作用:
    map 调用之前声明一个计数器变量。由于 JavaScript 的闭包特性,map 的回调函数可以访问并修改这个外部作用域的变量。

  • 代码示例:

    if (insertedPlaylistIdResponse?.id) {
      let positionCounter = 0; // 初始化计数器,如果希望 position  1 开始,这里用 0
    
      const playlistItems = insertedSongIdsResponse.map((songIdResponse) => {
        positionCounter++; // 每次迭代都增加计数器
        return {
          playlist_id: insertedPlaylistIdResponse.id,
          song_id: songIdResponse.id,
          position: positionCounter, // 使用累加后的计数器
        };
      });
      console.log('使用闭包计数器:', playlistItems);
      /*
      输出:
      使用闭包计数器: [
        { playlist_id: 1, song_id: 101, position: 1 },
        { playlist_id: 1, song_id: 102, position: 2 },
        { playlist_id: 1, song_id: 103, position: 3 }
      ]
      */
    }
    
  • 额外建议:

    • 这种方法灵活,但引入了外部状态的改变,可视为一种“副作用”。对于 map 这种期望纯函数的场景,不如 index 参数优雅。
    • 要确保计数器的初始值和增量逻辑正确,以得到期望的 position 序列(从0开始还是从1开始等)。
    • 如果 map 操作是异步的,或者在更复杂的并发环境中使用,需要注意这种共享状态可能引发的问题(虽然在这里的 map 回调是同步执行的)。

方案三:使用传统 for 循环或 for...of

有时候,如果操作逻辑变得复杂,或者你想更明确地控制迭代和状态,传统的循环结构反而是个不错的选择。

  • 原理与作用:
    直接使用 for 循环或 for...of 循环遍历数组,在循环体内部维护你自己的计数器,并构建新的数组。

  • 代码示例 (使用 for...of):

    if (insertedPlaylistIdResponse?.id) {
      const playlistItems = [];
      let position = 1; // 直接初始化为1,或者0然后在循环内先++
      for (const songIdResponse of insertedSongIdsResponse) {
        playlistItems.push({
          playlist_id: insertedPlaylistIdResponse.id,
          song_id: songIdResponse.id,
          position: position,
        });
        position++;
      }
      console.log('使用 for...of 循环:', playlistItems);
      /*
      输出:
      使用 for...of 循环: [
        { playlist_id: 1, song_id: 101, position: 1 },
        { playlist_id: 1, song_id: 102, position: 2 },
        { playlist_id: 1, song_id: 103, position: 3 }
      ]
      */
    }
    
  • 额外建议:

    • 这种方式非常直观,易于理解,对状态的控制也最直接。
    • 虽然代码量可能比 map 略多,但在某些复杂场景下,其明确性能带来更好的可读性。

方案四:强行让 thisArg 工作(不推荐用于此场景,但解释其机制)

为了完整地回答“thisArg 能否用来做计数器”的问题,我们可以探索一下如果非要用 thisArg (配合常规函数 而非箭头函数) 来传递一个可变状态,该怎么做。这通常需要 thisArg 是一个对象,我们修改该对象的属性。

  • 原理与作用:
    将一个包含计数器属性的对象作为 thisArg 传递给 map。在常规函数 回调中,this 会指向这个对象,然后我们可以读取并修改它的属性。

  • 代码示例:

    if (insertedPlaylistIdResponse?.id) {
      const counterObject = { position: 0 }; // 注意:用一个对象包装计数器
    
      const playlistItems = insertedSongIdsResponse.map(function(songIdResponse) {
        // 必须使用常规函数,function() {}
        this.position++; // 修改 this 指向的 counterObject 的属性
        return {
          playlist_id: insertedPlaylistIdResponse.id,
          song_id: songIdResponse.id,
          position: this.position,
        };
      }, counterObject); // 将 counterObject 作为 thisArg 传入
    
      console.log('强行使用 thisArg (常规函数):', playlistItems);
      /*
      输出:
      强行使用 thisArg (常规函数): [
        { playlist_id: 1, song_id: 101, position: 1 },
        { playlist_id: 1, song_id: 102, position: 2 },
        { playlist_id: 1, song_id: 103, position: 3 }
      ]
      */
      console.log('外部 counterObject 的状态:', counterObject); // { position: 3 }
    }
    
  • 额外建议:

    • 强烈不推荐 为这么简单的计数需求使用这种方法。它比使用 index 或闭包要复杂得多,也更容易出错(比如忘记用常规函数)。
    • 它主要用来演示 thisArg 的一种用法:传递一个共享的上下文对象,其状态可以在多次回调调用中被修改和访问。
    • 如果你真的需要一个复杂的共享上下文,并且不想用闭包(比如这个上下文对象在多处被传递和使用),这或许有其用武之地。但对于简单计数,它是大材小用且徒增困惑。

三、到底用哪种?

对于在 map 中生成递增序号的需求:

  1. 首选方案一(使用 index 参数): 这是最简洁、最直观、最符合函数式编程风格的做法。它没有任何副作用,代码清晰易懂。
  2. 其次可选方案二(闭包): 如果计数逻辑稍微复杂一点,或者你想在 map 之外也能访问到这个计数器的最终状态,闭包是可行的。但要注意它引入了外部状态的副作用。
  3. 方案三(传统循环): 如果转换逻辑非常复杂,或者你偏爱命令式编程风格,传统循环提供了最大的灵活性。
  4. 方案四(thisArg 与常规函数): 几乎不应为简单的计数器场景考虑。理解它的机制有助于深入掌握 this,但在实践中应避免滥用。

回到最初的问题,在 TypeScript(或 JavaScript)中,直接用 Array.map()thisArg 参数将一个原始数字作为可自增的计数器,尤其是在箭头函数中,是行不通的。正确理解 this 的行为和 map 的参数特性,能帮助我们写出更优雅、更健壮的代码。在多数情况下,map 回调函数的 index 参数就足以满足我们的计数需求了。