JS Why Not 系列

为什么避免使用 ?. 操作符?

这里还是首先澄清一下,这一段并不是说不要使用可选链操作符,相反,可选链操作符由于语言支持的原因,比起任何其他方案(比如 lodash.get 或者 undefsafe)都要好(例如,对于类型系统的支持、JS 引擎优化等等)。那么,为什么还是要单独提出避免使用呢?

空对象模式

实际上这里并不针对可选链单种操作符:

// Optional chaining
const myVar = myObject.prop_a?.prop_b

以下的写法同样是需要斟酌的(或操作符):

// OR operator
const myVar = myObject.prop_a || {}

这些写法本质上是一样的。考虑第一个可选链的例子,实际上等价于

const propA = myObject.prop_a || {}
const myVar = propA.prop_b

某些情况下喜欢用空对象模式来形容这种操作。所谓空对象模式就是,当某个数据为空时,将其作为一个空对象处理。

类型问题

空对象模式有一定的好处。例如,考虑以下例子:

function getFoo(arg) {
  return arg.foo || {}
}

function getBar(arg) {
  return getFoo(arg).bar || {}
}

function getBaz(arg) {
  return getBar(arg).baz || {}
}

当传入的 arg 参数缺少 foo 时,调用 getBaz 依然可以保证不出错。

但是空对象模式仍然是一种相当反模式的用法。考虑上述的 arg 参数的类型,本应为

type Arg = {
  foo: {
    bar: {
      baz: unknown
    }
  }
}

但是当我们使用空对象时,实际上这个类型数据在后续的调用中丢失了,意味着它变成了

type Arg = {
  foo?: {
    bar?: {
      baz: unknown
    }
  }
}

向下污染

上面的例子实际上说明了,使用可选链或者或操作符默认值的一个缺陷是向下污染。考虑一个具体的例子,假设我们在写一个组件:

const FooComponent = props => (
  <div>{props.info.name}</div>
)

const BarComponent = props => (
  <FooComponent info={props.data.user.info} />
)

可能正常情况下这个组件工作得很好,但某个情况下,传给 BarComponentdata 没有 user 这个属性。按照上面的模式,代码会变成

const FooComponent = props => (
  <div>{props.info?.name}</div>
)

const BarComponent = props => (
  <FooComponent info={props.data.user?.info} />
)

原本只是 BarComponent 对数据需要额外处理,现在变成了 FooComponent 也需要修改对应的实现了!这里显然,一个更好的解决方案是

const FooComponent = props => (
  <div>{props.info.name}</div>
)

const BarComponent = props => (
  props.data.user ? <FooComponent info={props.data.user.info} /> : <div />
)

展现

另一个问题是,使用可选链/或操作符可能错误地掩盖了我们遇到的问题。依然考虑上面的例子,当我们使用可选链来解决这个问题的时候,实际上我们解决的不是问题,而是报错。对于很多前端框架而言,代码依然在按照报错的情况运行,只是我们不再看得到 Cannot read property 'info' of undefined 了。通常这意味着我们忽略了真正的问题:

为什么传给 BarComponentdata 没有 user 这个属性?

显然对于这个问题,在不同的场景下都应该有更合适的处理方式:

  • 如果是加载状态引起的,是否应该在组件内展示一个加载中的图标,而不是直接什么都不展示?
  • 如果是接口失败引起的,是否应该是给出一个加载失败的提示?
  • 如果是数据格式变动引起的,是否应该考虑这里需要兼容新/旧数据格式?
  • …或者,我们根本就用错了组件呢?

其实可选链操作符很多时候还是有用的,但这种情况通常只发生在【对应的参数原本就接受一个可以为空的数据】的情况。如果大家觉得不好判断的话,只要在用的时候多想想,是否现在或者未来会有其他边缘情况的问题就好了;或者思考一下,当我们使用 TypeScript 去写的时候,是否就会有问题暴露出来?

为什么避免使用 .then() 等方法?

回顾一下异步处理的历史:

  • ES3:callback
  • ES2015:Promise、co
  • ES2017:async function
  • ES2019:async iterator
  • ……

异步处理的方式随着语言的发展越来越多,我们很多时候是全部都在使用的。但这里想要提的是,我们应当尽量避免使用 Promise 的原型方法,而是使用 async function 来代替。

可读性

众所周知,async function 的可读性远大于 Promise。这是因为 Promise 本身就是异步操作的原语,相当于是说,Promise 实际上只提供了底层接口,而 async function 则是开发层面的使用方式。

简单对比一下:

async function checkStatus(time, interval) {
  return new Promise(resolve => {
    function poll() {
      if (time <= 0) {
        resolve(undefined)
        return
      }
      fetchValue().then(value => {
        if (value) {
          resolve(value)
          return
        }
        time -= 1
        setTimeout(poll, interval)
      })
    }
  })
}

function wait(interval) {
  return new Promise(resolve => setTimeout(resolve, interval))
}

async function checkStatus(time, interval) {
  let value
  while (time > 0) {
    try {
      value = await fetchValue()
      if (value) return value
    } catch {
      // continue
    }
    await wait(interval)
    time -= 1
  }
  return value
}

显然下面的可读性更好(而且是在上面的代码已经被抽象过的条件下,不然会更加糟糕)

为什么会有可读性的差异呢?除了所谓的可以写成同步的方式之外,一个比较重要的原因在于:我们可以在 async function 里使用大部分的语言结构(for/while/try-catch 等),JS 已经为其做了同步/异步的处理。

异步化

继续上面的这一点来展开的话,之所以在 async function 内可以使用多数语言结构,一个重要的原因在于,await 操作本身语义也被统一了。考虑一下:

let value
function getValue() {
  if (value) return value
  return new Promise(resolve => {
    return fetchValue()
  }).then((result) => {
    value = result
    return value
  })
}

上面的例子似乎也挺自然的,如果有一个缓存的值则使用这个值,如果没有则返回一个 Promise。就好像我们写

let value
async function getValue() {
  if (value) return value
  value = await fetchValue()
  return value
}

好像也没什么区别呀?

但是,正如这篇文章要讨论的,考虑下面的调用情况

function printValue() {
  getValue().then(value => {
    console.log(value)
  })
}

在第一个例子中,这段代码会有一个问题,就是当 value 有缓存值的时候,getValue 的返回值并没有 then 方法。除非:

function printValue() {
  const result = getValue()
  if (typeof result.then === 'function') {
    result.then(value => {
      console.log(value)
    })
  } else {
    console.log(result)
  }
}

或者聪明的同学会想到

function printValue() {
  Promise.resolve(getValue()).then(value => {
    console.log(value)
  })
}

然而,如果我们使用第二个例子是,实际上 async 关键字已经将我们的返回值统一成了 Promise,在调用的时候便没有后顾之忧了。

不仅如此,假设我们的调用是

async function printValue() {
  const value = await getValue()
  console.log(value)
}

那么不论上面哪一种写法,都是可以工作的,因为 await 关键字也已经将返回值统一成了 Promise 来处理。这意味着当我们使用 async 或者 await 的时候,同步值也会作为异步来处理,也就不需要我们关心两者的区别。

兼容性

另一个问题是,原型方法只能 polyfill,而 async/await 及其涉及的语法可以转译。两者的最大区别在于转译可以保证生产环境使用兼容的代码,而原型 polyfill 只能靠 browserslist 的声明来推断。

在这个问题上,最明显的表现是 Promise#finally(ES2019)。当某些浏览器没有被 browserslist 覆盖到时,使用 .finally 就会报错。

尽管目前在肉眼可见的未来内,Promise 对象不会有新的原型方法了,我们只需要对 .finally做特殊处理就可以;但是未来的事情,谁又能说清呢?:)


Powered by Sairin