跳到正文

我所理解的 JavaScript 最佳实践(4):按遍历任务选择循环和数组方法

我所理解的 JavaScript 最佳实践

  1. 尽量去掉只用一次的中间变量
  2. 遍历的起点决定按 ID 取数的顺序
  3. 不用 reduce
  4. 按遍历任务选择循环和数组方法(当前)
  5. 异步遍历按任务依赖选择并发或串行
  6. 卫语句让主流程保持平直
  7. 用函数给可复用的业务规则命名
  8. 让对象字段保持有序
  9. 正确写注释
  10. 一行代码只做一件事
  11. 给函数起合适的名字
  12. 用空值合并运算符更好地控制默认值生效逻辑
  13. 用队列串行处理偏好更新,避免修改互相覆盖
  14. 确认弹窗关闭时,让等待中的 Promise 得到结果
  15. 用独立的预期值捕获实现错误

看到 tasks[index],我会再扫一遍循环体,看看这段代码是不是真的用到了位置。如果 index 只在取元素时出现,直接遍历任务就够了。下标和步长参与操作时,再保留普通的 for。

按当前值、下标和终止条件选择循环

逐项执行操作用 for…of

下面按列表顺序为任务登记曝光。登记只需要当前任务的 ID。

for (let index = 0; index < tasks.length; index += 1) {
  recordImpression(tasks[index].id);
}

去掉下标后,读者在循环头就能看见每轮处理一条任务。

for (const task of tasks) {
  recordImpression(task.id);
}

forEach 也能逐项执行同步操作。我通常用 for…of,因为循环体可以直接用 continue 跳过当前项,用 break 结束整个循环。比如按优先级尝试打开任务,先跳过禁用项,成功打开一项就停止。

for (const task of tasks) {
  if (task.disabled) {
    continue;
  }

  if (await openTask(task)) {
    break;
  }
}

在 forEach 的回调里写 return 只会结束本次回调。它也不会等待异步回调返回的 Promise。批量提交怎样等待请求,可以接着看第五篇的异步遍历。

转换和查找用直接返回结果的方法

map 表达的是把每项转换成一个结果。例如任务列表要显示标签,可以直接写 const labels = tasks.map(task => task.title)。看到 map 就能预期拿到一个新数组,回调负责说明每个标签怎样产生。要留下所有符合条件的项,就用 filter。

只想找一条记录时,也可以把「索引循环、条件、保存结果、break」交给数组方法。例如 A、B、C、D 四条记录里,B 和 D 已过期。以下写法共用同一个条件。

const isExpired = item => item.status === 'expired';
const hasExpired = items.some(isExpired); // true
const firstExpired = items.find(isExpired); // B
const expiredItems = items.filter(isExpired); // B、D

some 回答有没有,find 取第一条,两者在 B 命中就停。filter 检查剩余记录,拿到全部匹配项。没有匹配时,三者分别返回 false、undefined 和空数组。把 filter(...)[0] 改成 find 前,确认回调只做判断,原先每项都执行的日志或更新会随提前停止一起减少。

找到过期项时,some 和 find 在 B 停下。filter 继续收集 D;无匹配时三个方法都查完整个列表

自有对象字段先取键、值或键值对

for…in 会枚举对象及其原型链上可枚举的字符串键。多数业务对象只需要自己的字段。这时可以直接从想要的结果出发。Object.keys 取键,Object.values 取值,Object.entries 取键值对,三者都只取自有、可枚举的字符串键所对应的属性。

for (const [field, value] of Object.entries(payload)) {
  recordField(field, value);
}

这也省去了在 for…in 的每轮手动调用 Object.hasOwn。如果业务确实需要枚举继承字段,才保留 for…in。ECMAScript 的对象枚举方法

其他常用操作也能直接表达结果。项目已有 es-toolkit 时,可以用 uniq 给标签去重,用 sum 加总一组数字。已有键值对时,用 Object.fromEntries 转成对象,同键会保留后写入的值。更多分组、计数和建索引的写法见第三篇《不用 reduce》。
es-toolkit 的 uniq · es-toolkit 的 sum

下标参与操作时保留普通 for

列表是 [A, B, C, D],B 和 C 相邻且都过期。如果调用方要求保留原数组的引用,可以从后往前删除。

for (let index = tasks.length - 1; index >= 0; index -= 1) {
  if (tasks[index].expired) {
    tasks.splice(index, 1);
  }
}

先在下标 2 删除 C,再在下标 1 删除 B,最终剩下 [A, D]。如果从前往后删 B 后直接递增下标,C 会左移到刚处理过的位置,被跳过去。允许创建新数组时,可以用 filter 保留未过期的任务。

while 在第一轮前检查条件

队列里有 A、B、C,这次最多执行两条。开始前先检查队列和剩余预算。预算一开始就是 0 时,A 也不会被取出来。

let remaining = 2;
while (queue.length > 0 && remaining > 0) {
  const task = queue.shift();
  await runTask(task);
  remaining -= 1;
}

这里的队列是只存任务的普通密集数组,检查长度和取出任务之间没有等待。A 和 B 各消耗一份预算,C 留在队列里。类似地,沿着父节点逐层向上查找时也可以用 while。每一轮只取下一个父节点,走到根就结束。

do…while 先执行一次再决定是否继续

打开任务历史页时,虽然还没有游标,第一页也必须请求。收到响应之后,才知道是否有下一页。

let cursor: string | undefined;

do {
  const page = await fetchPage(cursor);
  appendItems(page.items);
  cursor = page.nextCursor;
} while (cursor !== undefined);

这个接口用 undefined 请求第一页,也用它表示没有下一页。第一页给出 'p2' 就继续,否则一轮结束。读这种循环时,可以直接沿着游标看下一轮由什么触发。队列预算、父节点和游标都在运行中变化,和固定走过一组数组下标是两种不同的循环条件。