Published on

为什么要用React Hooks[翻译]

Authors
  • avatar
    Name
    祝你好运
    Twitter

本文翻译自:Why React Hooks?

任何你要学习新知识的时候,你都得问一下你自己两个问题:

  • 为什么会有这东西?
  • 它解决了什么问题?

如果你找不到让你信服的答案,那你后面学习的细节就没有一个坚实的基础。React Hooks的这两个问题就尤其有意思。在Hooks发布的时候React就已经是JavaScript生态系统中最流行也最受前端开发者喜爱的开发框架。但即使有了这么多赞誉,React开发团队还是认为开发和发布Hooks是有必要的。那这是为什么呢?想要更好的回答这两个问题,我们首先需要深入的看一下我们在老版本中是如何构建React应用的。

createClass

如果你是用React足够早,那你一定对React.createClass很熟悉。这就是我们创建React类的方法,所有我们用来描述组件的信息都是以对象的形式传给createClass方法的。

const ReposGrid = React.createClass({
  getInitialState() {
    return {
      repos: [],
      loading: true,
    }
  },
  componentDidMount() {
    this.updateRepos(this.props.id)
  },
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.updateRepos(this.props.id)
    }
  },
  updateRepos(id) {
    this.setState({ loading: true })

    fetchRepos(id).then((repos) =>
      this.setState({
        repos,
        loading: false,
      })
    )
  },
  render() {
    const { loading, repos } = this.state

    if (loading === true) {
      return <Loading />
    }

    return (
      <ul>
        {repos.map(({ name, handle, stars, url }) => (
          <li key={name}>
            <ul>
              <li>
                <a href={url}>{name}</a>
              </li>
              <li>@{handle}</li>
              <li>{stars} stars</li>
            </ul>
          </li>
        ))}
      </ul>
    )
  },
})

createClass就是一个简单而且有效的创建React组件的方法。最初React使用这个方法的原因就是在当时JavaScript还没有内置的类系统。当然,后来就有了。ES6给我们带来了class关键字,以及原生的创建类的方法。这就让React的处境很棘手,要么逆着JavaScript的发展潮流继续使用createClass,要么按照EcmaScript的标准,拥抱类。历史证明他们选择了后者。

React.Component

我们发现我们并不是在设计一个类系统,我们仅仅是想要用JavaScript的方式来创建类 - [React v0.13.0 Release](https://reactjs.org/blog/2015/01/27/react-v0.13.0-beta-1.html)

React 0.13.0引入了让我们可以用原生JavaScript方法来创建类的API React.Component。站在EcmaScript标准的角度,这的确是一大进步。

class ReposGrid extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      repos: [],
      loading: true,
    }

    this.updateRepos = this.updateRepos.bind(this)
  }
  componentDidMount() {
    this.updateRepos(this.props.id)
  }
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.updateRepos(this.props.id)
    }
  }
  updateRepos(id) {
    this.setState({ loading: true })

    fetchRepos(id).then((repos) =>
      this.setState({
        repos,
        loading: false,
      })
    )
  }
  render() {
    if (this.state.loading === true) {
      return <Loading />
    }

    return (
      <ul>
        {this.state.repos.map(({ name, handle, stars, url }) => (
          <li key={name}>
            <ul>
              <li>
                <a href={url}>{name}</a>
              </li>
              <li>@{handle}</li>
              <li>{stars} stars</li>
            </ul>
          </li>
        ))}
      </ul>
    )
  }
}

虽然在正确的方向前进了一大步,React.Component并不是没有缺点。

constructor

通过使用类组件,你可以在constructor里面初始化你的实例(this)上面的state属性。然而,根据EcmaScript规范,如果你继承一个类,那你必须在使用this之前调用super。尤其是,当你用React的时候,你还要记住把props传递给super

  constructor (props) {
    super(props) // ð

    ...
  }

自动绑定

当使用createClass的时候,React魔幻般的帮我们绑定了所有的方法到组件的this实例上。但如果用了React.Component,就不会是那回事了。很快,全世界的React开发者都意识到自己搞不动this关键字的原理。你必须在类的constructor方法里面把你的所有方法bind一遍,真怀念之前那种稀里糊涂就能正常工作的日子。如果你不这么做,大名鼎鼎的Cannot read property setState of undefined错误就会出现。

  constructor (props) {
    ...

    this.updateRepos = this.updateRepos.bind(this) // ð
  }

当然你可能会想,这些都是小问题,虽然需要调用super(props)bind方法让人很烦,但这些都不是原则性的错误。而且,这些其实是JavaScript类的锅。但是,我们是开发者,即便是这些小问题,一天遇到20多次,我们也受不了。幸运的是,我们从createClass切换到React.Component不久,Class Fields提案被创建了。

class fields

class fields让你无需使用constructor就可以给类添加属性。这对我们来说有啥好处呢?上面两个问题都可以通过这个解决。我们不再需要在constructor里面初始化我们的state,也不需要在constructor里面bind我们的方法,因为我们有了箭头函数大杀器!

class ReposGrid extends React.Component {
  state = {
    repos: [],
    loading: true,
  }
  componentDidMount() {
    this.updateRepos(this.props.id)
  }
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.updateRepos(this.props.id)
    }
  }
  updateRepos = (id) => {
    this.setState({ loading: true })

    fetchRepos(id).then((repos) =>
      this.setState({
        repos,
        loading: false,
      })
    )
  }
  render() {
    const { loading, repos } = this.state

    if (loading === true) {
      return <Loading />
    }

    return (
      <ul>
        {repos.map(({ name, handle, stars, url }) => (
          <li key={name}>
            <ul>
              <li>
                <a href={url}>{name}</a>
              </li>
              <li>@{handle}</li>
              <li>{stars} stars</li>
            </ul>
          </li>
        ))}
      </ul>
    )
  }
}

感觉很棒!对不对?完美了?其实并没有。有一些影响深远的问题,跟之前那两个问题一样,一直存在,只是大家讨论的不多。

React的核心观点就是你最好通过把你的应用拆成很多小的组件,然后把他们组合在一起,这样来管理你的应用的复杂度。这种组件模型,让React很优雅。问题并不在组件模型,在模型的实现方式上面。

重复逻辑

历史上,我们的组件是跟React的生命周期耦合在一起的。这种耦合导致我们把代码分散到组件的各个生命周期函数里面。通过下面的ReposGrid例子,我们可以很清晰的看到这点。想要把props.idrepos保持同步,我们需要三个方法(componentDidMount, componentDidUpdateupdateRepos)。

  componentDidMount () {
    this.updateRepos(this.props.id)
  }
  componentDidUpdate (prevProps) {
    if (prevProps.id !== this.props.id) {
      this.updateRepos(this.props.id)
    }
  }
  updateRepos = (id) => {
    this.setState({ loading: true })

    fetchRepos(id)
      .then((repos) => this.setState({
        repos,
        loading: false
      }))
  }

想要解决这个问题,我们需要一种全新的处理React组件副作用的范式。

共享非UI逻辑

当你思考React的组合的时候,你一般都会考虑UI的组合,因为这就是React所擅长的。

view = fn(state)

现实中,除了UI层,我们还有很多事情要做。组合和复用非UI逻辑还是很常见的。然而,因为React把UI和组件耦合在一起,所以,这就很难搞,React也没有很好的解决办法。

就拿我们的例子来说,假如我们需要创建另一个也需要repos状态的组件。现在,状态和逻辑都在ReposGrid组件里面,我们怎么样才能复用呢?最简单的办法就是复制粘贴,很诱人对不对?但是复制粘贴是最不好的办法,另一种好一点的办法就是用高阶组件来把相关的逻辑封装起来,然后把相关的state作为props传给需要用它的组件。

function withRepos(Component) {
  return class WithRepos extends React.Component {
    state = {
      repos: [],
      loading: true,
    }
    componentDidMount() {
      this.updateRepos(this.props.id)
    }
    componentDidUpdate(prevProps) {
      if (prevProps.id !== this.props.id) {
        this.updateRepos(this.props.id)
      }
    }
    updateRepos = (id) => {
      this.setState({ loading: true })

      fetchRepos(id).then((repos) =>
        this.setState({
          repos,
          loading: false,
        })
      )
    }
    render() {
      return <Component {...this.props} {...this.state} />
    }
  }
}

现在,任何需要用到repos(或者loading)的组件,我们都可以用withRepos来封装他。

// ReposGrid.js
function ReposGrid ({ loading, repos }) {
  ...
}

export default withRepos(ReposGrid)
// Profile.js
function Profile ({ loading, repos }) {
  ...
}

export default withRepos(Profile)

这个的确能做到复用非UI逻辑,而且和Render Props一起被作为推荐方案。但是他们也有缺陷。

首先,这增加了理解代码的心智负担。通过我们的withRepo高阶组件,我们有了一个函数,这个函数以我们最终渲染的组件作为参数,但是返回了一个新的类组件,在类组件里面就是我们要复用的代码。真是一个复杂难懂的过程。

其次,我们有多个高阶组件怎么办?正如你想象的那样,很快就失控了

export default withHover(withTheme(withAuth(withRepos(Profile))))

比上面更糟糕的就是渲染出来的结果,高阶组件强制你重新组织你的代码,这最终会导致封装地狱。

<WithHover>
  <WithTheme hovering={false}>
    <WithAuth hovering={false} theme='dark'>
      <WithRepos hovering={false} theme='dark' authed={true}>
        <Profile
          id='JavaScript'
          loading={true}
          repos={[]}
          authed={true}
          theme='dark'
          hovering={false}
        />
      </WithRepos>
    </WithAuth>
  <WithTheme>
</WithHover>

现状

这就是我们目前的状况:

  • React相当流行
  • 我们使用类组件,因为这在当时就是最合适的
  • 调用super(props)很烦人
  • 没人知道this的原理
  • 好,冷静,我知道你知道this的原理,但是这真的是不必要的麻烦
  • React没有很好用的复用非UI逻辑的模式

我们需要新的组件API来解决上述问题,而且它要简单、灵活、可组合而且可扩展。真难搞,但是React团队竟然搞定了!

React Hooks

从React v0.14.0开始,我们有两种创建组件的方式,函数组件和类组件。他们之间的区别就是,如果我们需要用状态,或者需要用组件的生命周期函数,那我们就只能用类组件。否则,我们可以用函数组件。

那我们能不能站着,把钱挣了,能不能只用函数组件?

有时候,优雅的解决方案就是一个函数,不是一个方法,不是一个类,更不是一个框架,就是一个函数

  • 约翰·卡马克 Oculus VR CTO

当然我们需要找到让函数组件拥有状态和生命周期函数的方法。假设我们已经找到了,那能给我们带来什么好处呢?

首先我们不用调用super(props)了,也不用bind我们的方法了,也不用使用Class Fields了,而且前面我们讨论的所有问题统统都没有了。

(ノಥ,_」ಥ)ノ彡 React.Component ð

function (Ő‿Ő✿)

重点来了

  • 状态
  • 生命周期函数
  • 复用非UI逻辑

状态

既然我们不用类组件,那我们就需要一种新的方式来添加和管理我们的状态。在React v16.8.0中,React给我们带来了useState

useState是这个课程中众多钩子中的第一个,随着课程的继续,我们会更加深入的研究useState

useState只接受一个参数,就是初始状态。返回值是两个值,第一个是状态的值,第二个是修改状态的函数。

const loadingTuple = React.useState(true)
const loading = loadingTuple[0]
const setLoading = loadingTuple[1]

...

loading // true
setLoading(false)
loading // false

正如你看到的那样,从数组里面一个一个的取值是相当不舒服的姿势,这仅仅是为了说明useState的返回值是一个数组。通常情况下我们都会用数组的解构赋值来在一行里面取值

// const loadingTuple = React.useState(true)
// const loading = loadingTuple[0]
// const setLoading = loadingTuple[1]

const [loading, setLoading] = React.useState(true) // 👌

现在让我们用我们新找到的useState钩子来更新我们的ReposGrid组件

function ReposGrid({ id }) {
  const [repos, setRepos] = React.useState([])
  const [loading, setLoading] = React.useState(true)

  if (loading === true) {
    return <Loading />
  }

  return (
    <ul>
      {repos.map(({ name, handle, stars, url }) => (
        <li key={name}>
          <ul>
            <li>
              <a href={url}>{name}</a>
            </li>
            <li>@{handle}</li>
            <li>{stars} stars</li>
          </ul>
        </li>
      ))}
    </ul>
  )
}
  • 状态 ✔
  • 生命周期函数
  • 共享非UI逻辑

生命周期函数

这里可能会让你有些忧伤(或者高兴?),你必须忘记所有传统React生命周期函数。我们已经见识到了用生命周期函数的方式所带来的问题。“这种[生命周期函数]导致我们把代码分散到组件的各个组件里面”,这次,我们以同步的方式来思考。 任何我们考虑生命周期事件的时候,我们就考虑怎么用同步。不论它是设置初始状态、获取数据、更新DOM还是别的任何事。具体来说就是,从React外面(服务器,DOM,等)到React里面(状态),反过来也一样。 当你以同步,而不是生命周期事件来思考的时候,我们可以把相关的逻辑组合到一起。我们可以用React的useEffect来达到这一目的。 正如名字一样,useEffect让我们可以在函数组件里面产生副作用,它接受两个参数,一个函数和一个可选的数组。函数定义了如何产生副作用,而(可选)数组定义了何时“重新同步”(或者重跑)副作用。

React.useEffect(() => {
  document.title = `Hello, ${username}`
}, [username])

在上面的代码中,只要username变化,函数就会被执行。因此就达到了使文档标题和Hello, ${username}保持同步的效果。

现在,我们怎么样才能在我们的代码里面用useEffect钩子来保持repos和我们的fetchReposAPI请求同步?

function ReposGrid({ id }) {
  const [repos, setRepos] = React.useState([])
  const [loading, setLoading] = React.useState(true)

  React.useEffect(() => {
    setLoading(true)

    fetchRepos(id).then((repos) => {
      setRepos(repos)
      setLoading(false)
    })
  }, [id])

  if (loading === true) {
    return <Loading />
  }

  return (
    <ul>
      {repos.map(({ name, handle, stars, url }) => (
        <li key={name}>
          <ul>
            <li>
              <a href={url}>{name}</a>
            </li>
            <li>@{handle}</li>
            <li>{stars} stars</li>
          </ul>
        </li>
      ))}
    </ul>
  )
}

相当花哨,对不对?我们成功的摆脱了React.Componentconstructorsuperthis,而且最重要的是我们的副作用逻辑不会重复出现在组件中。

  • state ✔
  • 生命周期函数 ✔
  • 共享非UI逻辑

共享非UI逻辑

之前我们提到React没法共享非UI逻辑的原因是“React的组件和UI耦合在一起”。这导致过于复杂的模式,比如高阶组件或者Render props。就像你猜的那样,Hooks能解决这些。然而,可能跟你想的不一样,没有内置的Hook来共享非UI逻辑,你得创建你自己的Hooks来从UI解耦。

我们可以通过创建我们自己的useReposHook来看一下,这个Hook接收一个Repos的id,返回一个数组,数组的第一个元素就是loading状态,第二个元素就是repos状态。

function useRepos(id) {
  const [repos, setRepos] = React.useState([])
  const [loading, setLoading] = React.useState(true)

  React.useEffect(() => {
    setLoading(true)

    fetchRepos(id).then((repos) => {
      setRepos(repos)
      setLoading(false)
    })
  }, [id])

  return [loading, repos]
}

这里最棒的就是任何与获取我们的repos有关的逻辑都可以被抽取到这个自定义的Hook中。现在,无论我们现在在哪个组件中,甚至是非UI逻辑,任何时候我们需要与repos有关的数据,我们就可以使用我们的useRepos自定义Hook。

function ReposGrid ({ id }) {
  const [ loading, repos ] = useRepos(id)

  ...
}
```jsx
function Profile ({ user }) {
  const [ loading, repos ] = useRepos(user.id)

  ...
}
  • state ✔
  • 生命周期函数 ✔
  • 共享非UI逻辑 ✔

Hooks的卖点就是让函数组件内部可以有状态,实际上,Hooks能做个的事情比这个更多。更好的代码重用,组合,更好的默认值。关于Hooks我们需要学的还有更多,但是现在你知道了为什么我们会存在,我们有了一个坚实的基础。