[译]<<Effective TypeScript>>ts技巧28:终选择表示有效状态的类型

213 阅读3分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第20天,点击查看活动详情

本文的翻译于<<Effective TypeScript>>, 特别感谢!! ps: 本文会用简洁, 易懂的语言描述原书的所有要点. 如果能看懂这文章,将节省许多阅读时间. 如果看不懂,务必给我留言, 我回去修改.

技巧28: 始终选择表示有效状态的类型

如果你的 types 设计的很糟糕, 那么拿的代码不仅不易阅读, 而且容易出现bug。一个高效 types 的设计原则:让你的 types 永远只表示有效状态。

假定你正在建立一个网站,你可以选择并且加载一个网页,你可能会 写一个 state 像这样:

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}

你在写渲染网页代码时候,会考虑所有情况:

unction renderPage(state: State) {
  if (state.error) {
    return `Error! Unable to load ${currentPage}: ${state.error}`;
  } else if (state.isLoading) {
    return `Loading ${currentPage}...`;
  }
  return `<h1>${currentPage}</h1>\n${state.pageText}`;
}

如果 isLoading 和 error 被同时设置了? 那我们是不是应该同时显示 loading 消息和 error 消息?

另外如果你写一个 changePage 函数:

async function changePage(state: State, newPage: string) {
  state.isLoading = true;
  try {
    const response =
await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const text = await response.text();
    state.isLoading = false;
    state.pageText = text;
  } catch (e) {
    state.error = '' + e;
  }
}

这段代码有很多问题:

  • 在 error 分支,我们忘记设置 state.isLoading 为 false
  • 在前一个请求失败,我们没有对 state.error 进行清空,那么导致加载新页面,还会报错
  • 当一个网页正在加载中,用户点击加载另一个网页,他们可能会看到一个新的网页,和之前网页的报错信息。

错误的原因:state的设计有问题:

  1. state信息太少:是哪个个网页请求失败? 哪个网页正在loading?
  2. state信息太冗余:当 isLoading , error被同时设置时,这表示一个非法的 state! 这让 render ,changePage 函数无法实现

这有一个 state 更好的实现:

interface RequestPending {
  state: 'pending';
}
interface RequestError {
  state: 'error';
  error: string;
}
interface RequestSuccess {
  state: 'ok';
  pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  requests: {[page: string]: RequestState};
}

这里运用了 tagged union 技术(可以称作discriminated union). 对网络请求可能处于的不同状态进行显式建模。这个版本的 state 占用内存会更多, 但是有一个非常大的好处:不包含非法state!所以 renderPage 和 changePage非常好实现:

function renderPage(state: State) {
  const {currentPage} = state;
  const requestState = state.requests[currentPage];
  switch (requestState.state) {
    case 'pending':
      return `Loading ${currentPage}...`;
    case 'error':
      return `Error! Unable to load ${currentPage}: ${requestState.error}`;
    case 'ok':
      return `<h1>${currentPage}</h1>\n${requestState.pageText}`;
  }
}

async function changePage(state: State, newPage: string) {
  state.requests[newPage] = {state: 'pending'};
state.currentPage = newPage;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const pageText = await response.text();
    state.requests[newPage] = {state: 'ok', pageText};
  } catch (e) {
    state.requests[newPage] = {state: 'error', error: '' + e};
  }
}

每个网页对应的 state 非常明确!

再来看一个非常简单但是可怕的例子:法航447航班,这架空客330在2009年6月1日在大西洋失踪。空中客车是一架电传飞机,驾驶员所有的控制都会输入计算机系统,然后输出控制飞机姿态。

空客330的驾驶舱为驾驶员和副驾驶配备了一套单独的控制装置。“侧杆”控制着攻角。向后拉会使飞机爬升,向前推会使飞机俯冲。空客330使用了一种称为“双输入”模式的系统,该模式允许两侧杆独立移动。以下是如何在TypeScript中对其状态进行建模:

interface CockpitControls {
  /** Angle of the left side stick in degrees, 0 = neutral, + = forward */
  leftSideStick: number;
  /** Angle of the right side stick in degrees, 0 = neutral, + = forward */
rightSideStick: number;
}

你需要写个 getStickSetting 函数获取操纵杆的状态:

function getStickSetting(controls: CockpitControls) {
  return controls.leftSideStick;
}

上面的代码仅仅获取了驾驶员操纵杆的状态,那副驾驶的操纵杆的状态呢?

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  }
  return leftSideStick;
}

这还有一个问题:如果驾驶员和副驾驶员的操纵杆的状态都为非零呢?, 有一个办法取平均:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  } else if (rightSideStick === 0) {
    return leftSideStick;
  }
  if (Math.abs(leftSideStick - rightSideStick) < 5) {
    return (leftSideStick + rightSideStick) / 2;
  }
  // ???
}

这导致了悲剧的发生:当法航447进入风暴后:

  1. 副驾驶员偷偷将操纵杆拉到最后。
  2. 飞机抬头,开始爬升高度的同时进入了失速状态。
  3. 驾驶员发现飞机失速,立即将操纵杆推倒最前,想让飞机重新获得速度。
  4. 但是副驾驶员的操纵杆依旧在最后没有被发现,这是当时法航获取操纵杆装动态的函数:
    function getStickSetting(controls: CockpitControls) {
      return (controls.leftSideStick + controls.rightSideStick) / 2;
    }
    
  5. 两个操纵杆状态取平均:操纵杆状态为0!最终飞机坠毁!

现如今飞机两个操纵杆状态相关联,如果副驾驶员的操纵杆往后拉,驾驶员的操纵杆将会保持同步。用 types 来表示:

interface CockpitControls {
  /** Angle of the stick in degrees, 0 = neutral, + = forward */
  stickAngle: number;
}

所以当你设计你的 types 应该考虑应该包含哪些 values ?应该排除哪些 values ?一定要排除非法的stause!