Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS Why Not 系列 #30

Open
CyanSalt opened this issue Dec 6, 2020 · 0 comments
Open

JS Why Not 系列 #30

CyanSalt opened this issue Dec 6, 2020 · 0 comments

Comments

@CyanSalt
Copy link
Owner

CyanSalt commented Dec 6, 2020


path: 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做特殊处理就可以;但是未来的事情,谁又能说清呢?:)

@CyanSalt CyanSalt transferred this issue from another repository Feb 24, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant