跳到正文

我所理解的 JavaScript 最佳实践(10):一行代码只做一件事

我所理解的 JavaScript 最佳实践

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

滚动区可以用已有标题作为无障碍名称。有标题 ID 时写入 aria-labelledby,标题被清空时移除旧属性。我在读 Marker 的滚动区组件时看到,两条分支各占一行,判断和 DOM 操作挤在一起。

if (title) element.setAttribute('aria-labelledby', title);
else element.removeAttribute('aria-labelledby');

我会把开括号接在 if 或 else 后面,让块内动作从下一行开始。第一次读可以先扫条件,确定要看哪条路,再往里读这条路修改了什么。

if (title) {
  element.setAttribute('aria-labelledby', title);
} else {
  element.removeAttribute('aria-labelledby');
}

缩进看起来属于分支,执行时可能已经出了分支

假设滚动节点为空时,原来只需要提前返回。后来还要在返回前断开观察器,往原来的卫语句里加一行,很容易写成下面这样。

function measureViewport(viewport, observer) {
  if (!viewport)
    observer.disconnect()
    return

  return viewport.getBoundingClientRect()
}

if 只管紧接着的 observer.disconnect()。即使 viewport 存在,下一行的 return 也会结束函数,最后的测量永远执行不到。大括号提前圈出了卫语句的边界,新增清理动作时,原来的 return 仍留在分支里。

function measureViewport(viewport, observer) {
  if (!viewport) {
    observer.disconnect();
    return;
  }

  return viewport.getBoundingClientRect();
}

Douglas Crockford 在 2005 年的《The Elements of JavaScript Style》里,用 Yahoo 的 Cookie 读取代码做过一个类似的演示。把后面的索引调整也缩进到单句 if 下面,缩进看似把它划入分支,JavaScript 仍会照常执行它。他给出的建议是「Always use blocks in structured statements.」

Google 的 JavaScript 风格指南也把非空块的第一条语句放到 { 后的新行,并保留 } else { 的连写。它允许足够短、没有 else 的简单 if 省略块。我在项目里连这种卫语句也加括号。维护代码时,一行 return 很容易变成「记录原因再返回」或「清理资源再返回」。

一行可以选一个值

滚动区还要处理 aria-label 和 role。如果把三组判断都展开,读者每次都要辨认「这个属性为什么有值」和「属性该设置还是移除」。可以先把名称规则写成值,再交给同一个 DOM 工具。下面的 setOrRemoveAttribute 来自 sheng-tool。给字符串就写入,给 null 就移除。

import { setOrRemoveAttribute } from 'sheng-tool';

function syncViewportName(viewport, label, titleId) {
  const ariaLabelledBy = titleId.trim() || null;
  const ariaLabel = ariaLabelledBy ? null : label.trim() || null;

  setOrRemoveAttribute(viewport, 'aria-labelledby', ariaLabelledBy);
  setOrRemoveAttribute(viewport, 'aria-label', ariaLabel);
  setOrRemoveAttribute(viewport, 'role', ariaLabelledBy || ariaLabel ? 'region' : null);
}

ariaLabelledBy ? null : label.trim() || null 这行只负责决定 aria-label 的值。标题 ID 优先,文字标签兜底。三元表达式的两个分支都在产出这个值,DOM 写入另起一行。读到调用处,也能直接确认每次操作的是哪个属性。

把条件触发的动作和执行顺序写出来

saved && notify() 可以只在保存成功时发通知。这儿没有后续代码需要读取表达式的结果,读者要找的是通知什么时候会发出。用 if 写明触发条件后,通知动作也有了可以加日志或断点的位置。

function reportSaveResult(saved, notify) {
  if (saved) {
    notify();
  }
}

同样,frozen ? '已冻结' : missingDocuments ? '缺材料' : '可审核' 压在一行时,读者要先找到第二个 ? 属于哪一支。如果这里还承担检查优先级,就把两条提前返回分别写出来。先冻结、再查材料,和业务规定的顺序一致。 《卫语句让主流程保持平直》

function getReviewLabel(frozen, missingDocuments) {
  if (frozen) {
    return '已冻结';
  }
  if (missingDocuments) {
    return '缺材料';
  }
  return '可审核';
}

没有条件分支也可能值得拆开。renderRows(normalizeRows(await loadRows())) 把等待请求、整理数据和更新界面套在一起。调试列表为空时,我想先看服务端有没有返回记录,再看转换丢了哪些记录,最后才查界面。这时两个只用一次的变量刚好给出了两处检查点。 《尽量去掉只用一次的中间变量》

async function refreshRows(loadRows, normalizeRows, renderRows) {
  const rawRows = await loadRows();
  const rows = normalizeRows(rawRows);
  renderRows(rows);
}

用 ESLint 要求大括号,用 Prettier 排版

ESLint 的 curly: ['error', 'all'] 会报告省略大括号的控制语句,并可自动修复。如果已经用了 eslint-config-prettier,把这条规则放在兼容配置后面。Prettier 会排开已有块里的语句,省略的大括号则由 ESLint 补齐。

import prettier from 'eslint-config-prettier/flat';

export default [prettier, { rules: { curly: ['error', 'all'] } }];

接手旧代码时,可以先用 eslint --fix 补上块,再跑一次 Prettier。代码审查时,把注意力留给工具看不出的事,例如两条检查谁先执行、哪一步会改状态、异步失败后还有哪些动作会继续。