React Hook实战总结 - 如何用React Hook写出没那么复杂的项目
使用React也有两个月了,总结下这两个月在实战中以及自己买的课程中的关于React Hook的一些收获。
React为什么要发明Hooks
React 组件的本质:一个状态到视图的函数
React 组件的模型其实很直观,就是从 Model 到 View 的映射,这里的 Model 对应到 React 中就是 state 和 props。如下图所示:
在过去,我们需要处理当 Model 变化时,DOM 节点应该如何变化的细节问题。而现在,我们只需要通过 JSX,根据 Model 的数据用声明的方式去描述 UI 的最终展现就可以了,因为 React 会帮助你处理所有 DOM 变化的细节。而且,当 Model 中的状态发生变化时,UI 会自动变化,即所谓的数据绑定。
所以呢,我们可以把 UI 的展现看成一个函数的执行过程。其中,Model 是输入参数,函数的执行结果是 DOM 树,也就是 View。而 React 要保证的,就是每当 Model 发生变化时,函数会重新执行,并且生成新的 DOM 树,然后 React 再把新的 DOM 树以最优的方式更新到浏览器。
既然如此,使用 Class 作为组件是否真的合适呢?Class 在作为 React 组件的载体时,是否用了它所有的功能呢?如果你仔细思考,会发现使用 Class 其实是有点牵强的,主要有两方面的原因。
一方面,React 组件之间是不会互相继承的。比如说,你不会创建一个 Button 组件,然后再创建一个 DropdownButton 来继承 Button。所以说,React 中其实是没有利用到 Class 的继承特性的。
另一方面,因为所有 UI 都是由状态驱动的,因此很少会在外部去调用一个类实例(即组件)的方法。要知道,组件的所有方法都是在内部调用,或者作为生命周期方法被自动调用的。
这也是为什么 React 很早就提供了函数组件的机制。只是当时有一个局限是,函数组件无法存在内部状态,必须是纯函数,而且也无法提供完整的生命周期机制。这就极大限制了函数组件的大规模使用。
Hook的诞生:把任何数据源想办法钩进任何函数组件
其实顺着函数组件的思路继续思考,就会发现,如果我们想要让函数组件更有用,目标就是给函数组件加上状态。这看上去似乎并不是难事。
简单想一下,函数和对象不同,并没有一个实例的对象能够在多次执行之间保存状态,那势必需要一个函数之外的空间来保存这个状态,而且要能够检测其变化,从而能够触发函数组件的重新渲染。
再进一步想,那我们是不是就是需要这样一个机制,能够把一个外部的数据绑定到函数的执行。当数据变化时,函数能够自动重新执行。这样的话,任何会影响 UI 展现的外部数据,都可以通过这个机制绑定到 React 的函数组件。在 React 中,这个机制就是 Hooks。
所以我们现在也能够理解这个机制为什么叫 Hooks 了。顾名思义,Hook 就是“钩子”的意思。在 React 中,Hooks 就是把某个目标结果钩到某个可能会变化的数据源或者事件源上,那么当被钩到的数据或事件发生变化时,产生这个目标结果的代码会重新执行,产生更新后的结果。
对于函数组件,这个结果是最终的 DOM 树;所以 Hooks 的结构可以如下图所示:Hooks的作用就是将所有的数据都当作数据源,钩进任何函数组件中,并且可以触发函数组件的更新
Hooks带来的好处
逻辑分离
关注点分离
逻辑分离
就以刚才我们提到的绑定窗口大小的场景为例。如果有多个组件需要在用户调整浏览器窗口大小时,重新调整布局,那么我们需要把这样的逻辑提取成一个公共的模块供多个组件使用。以 React 思想,在 JSX 中我们会根据 Size 大小来渲染不同的组件,例如:
1 | function render() { |
在 Class 组件的场景下,我们首先需要定义一个高阶组件,负责监听窗口大小变化,并将变化后的值作为 props 传给下一个组件。
1 | const withWindowSize = Component => { |
同样的逻辑如果用 Hooks 和函数组件该如何实现。首先我们需要实现一个 Hooks:
1 | const getSize = () => { |
关注分离
Hooks 的另一大好处:有助于关注分离除了逻辑复用之外,Hooks 能够带来的另外一大好处就是有助于关注分离,意思是说 Hooks 能够让针对同一个业务逻辑的代码尽可能聚合在一块儿。
这是过去在 Class 组件中很难做到的。因为在 Class 组件中,你不得不把同一个业务逻辑的代码分散在类组件的不同生命周期的方法中。所以通过 Hooks 的方式,把业务逻辑清晰地隔离开,能够让代码更加容易理解和维护。
反思
逻辑分离是可以让逻辑从函数组件中脱离出来,减少函数组件的长度,
关注分离是可以让相关的逻辑在一起,比如同一个自定义Hook中,而不是散落在函数组件内部的各处。
突然发现,之前喜欢把各种useState,useEffect都塞进函数组件中不是很好,这就导致了函数组件会很长,而且又让所有逻辑都在一个函数中混杂了,完全没用到逻辑分离和关注分离的好处。
几个内置Hooks的作用以及使用思考
useState:让函数组件具有维持状态的能力
useState 这个 Hook 就是用来管理 state 的,它可以让函数组件具有维持状态的能力。也就是说,在一个函数组件的多次渲染之间,这个 state 是共享的。下面这个例子就显示了 useState 的用法:
1 | import React, { useState } from 'react'; |
useState 这个 Hook 的用法总结出来就是这样的:
- useState(initialState) 的参数 initialState 是创建 state 的初始值,它可以是任意类型,比如数字、对象、数组等等(如果你想要缓存一个函数,应该使用useCallback,如果useState的initialState是个函数,则会缓存函数的返回结果)。
- useState() 的返回值是一个有着两个元素的数组。第一个数组元素用来读取 state 的值,第二个则是用来设置这个 state 的值。在这里要注意的是,state 的变量(例子中的 count)是只读的,所以我们必须通过第二个数组元素 setCount 来设置它的值。
- 如果要创建多个 state,那么我们就需要多次调用 useState。
通常来说,我们要遵循的一个原则就是:state 中不要保存可以通过计算得到的值。
从 props 传递过来的值。有时候 props 传递过来的值无法直接使用,而是要通过一定的计算后再在 UI 上展示,比如说排序。那么我们要做的就是每次用的时候,都重新排序一下,或者利用某些 cache 机制,而不是将结果直接放到 state 里。
从 URL 中读到的值。比如有时需要读取 URL 中的参数,把它作为组件的一部分状态。那么我们可以在每次需要用的时候从 URL 中读取,而不是读出来直接放到 state 里。
从 cookie、localStorage 中读取的值。通常来说,也是每次要用的时候直接去读取,而不是读出来后放到 state 里。
useEffect:执行副作用
useEffect ,顾名思义,用于执行一段副作用。什么是副作用呢?
通常来说,副作用是指一段和当前执行结果无关的代码。比如说要修改函数外部的某个变量,要发起一个请求,等等。
也就是说,在函数组件的当次执行过程中,useEffect 中代码的执行是不影响渲染出来的 UI 的。
对应到 Class 组件,那么 useEffect 就涵盖了 ComponentDidMount、componentDidUpdate 和 componentWillUnmount 三个生命周期方法。不过如果你习惯了使用 Class 组件,那千万不要按照把 useEffect 对应到某个或者某几个生命周期的方法。你只要记住,useEffect 是每次组件 render 完后判断依赖并执行就可以了。
useEffect 还有两个特殊的用法:没有依赖项,以及依赖项作为空数组。我们来具体分析下。
- 没有依赖项,则每次 render 后都会重新执行。
- 空数组作为依赖项,则只在首次执行时触发。
除了这些机制之外,useEffect 还允许你返回一个函数,用于在组件销毁的时候做一些清理的操作。比如移除事件的监听。
那么在定义依赖项时,我们需要注意以下三点:
- 依赖项中定义的变量一定是会在回调函数中用到的,否则声明依赖项其实是没有意义的。
- 依赖项一般是一个常量数组,而不是一个变量。因为一般在创建 callback 的时候,你其实非常清楚其中要用到哪些依赖项了。
- React 会使用浅比较来对比依赖项是否发生了变化,所以要特别注意数组或者对象类型。如果你是每次创建一个新对象,即使和之前的值是等价的,也会被认为是依赖项发生了变化。这是一个刚开始使用 Hooks 时很容易导致 Bug 的地方
useCallback:缓存回调函数
在 React 函数组件中,每一次 UI 的变化,都是通过重新执行整个函数来完成的,这和传统的 Class 组件有很大区别:函数组件中并没有一个直接的方式在多次渲染之间维持一个状态。
比如下面的代码中,我们在加号按钮上定义了一个事件处理函数,用来让计数器加 1。但是因为定义是在函数组件内部,因此在多次渲染之间,是无法重用 handleIncrement 这个函数的,而是每次都需要创建一个新的:
1 | function Counter() { |
每次组件状态发生变化的时候,函数组件实际上都会重新执行一遍。在每次执行的时候,实际上都会创建一个新的事件处理函数 handleIncrement。这个事件处理函数中呢,包含了 count 这个变量的闭包,以确保每次能够得到正确的结果。
这也意味着,即使 count 没有发生变化,但是函数组件因为其它状态发生变化而重新渲染时,这种写法也会每次创建一个新的函数。创建一个新的事件处理函数,虽然不影响结果的正确性,但其实是没必要的。因为这样做不仅增加了系统的开销,更重要的是:每次创建新函数的方式会让接收事件处理函数的组件,需要重新渲染。
useMemo:缓存计算的结果
useMemo 的 API 签名如下:
1 | useMemo(fn, deps); |
这里的 fn 是产生所需数据的一个计算函数。通常来说,fn 会使用 deps 中声明的一些变量来生成一个结果,用来渲染出最终的 UI。这个场景应该很容易理解:如果某个数据是通过其它数据计算得到的,那么只有当用到的数据,也就是依赖的数据发生变化的时候,才应该需要重新计算。
举个例子,对于一个显示用户信息的列表,现在需要对用户名进行搜索,且 UI 上需要根据搜索关键字显示过滤后的用户,那么这样一个功能需要有两个状态:
- 用户列表数据本身:来自某个请求。
- 搜索关键字:用户在搜索框输入的数据。
在这个例子中,无论组件为何要进行一次重新渲染,实际上都需要进行一次过滤的操作。但其实你只需要在 users 或者 searchKey 这两个状态中的某一个发生变化时,重新计算获得需要展示的数据就行了。那么,这个时候,我们就可以用 useMemo 这个 Hook 来实现这个逻辑,缓存计算的结果:
1 | //... |
这个时候,如果我们结合 useMemo 和 useCallback 这两个 Hooks 一起看,会发现一个有趣的特性,那就是 useCallback 的功能其实是可以用 useMemo 来实现的。比如下面的代码就是利用 useMemo 实现了 useCallback 的功能:
1 | const myEventHandler = useMemo(() => { |
useRef:在多次渲染之间共享数据
在类组件中,我们可以定义类的成员变量,以便能在对象上通过成员属性去保存一些数据。但是在函数组件中,是没有这样一个空间去保存数据的。因此,React 让 useRef 这样一个 Hook 来提供这样的功能。useRef 的 API 签名如下:
1 | const myRefContainer = useRef(initialValue); |
假设你要去做一个计时器组件,这个组件有开始和暂停两个功能。很显然,你需要用 window.setInterval 来提供计时功能;而为了能够暂停,你就需要在某个地方保存这个 window.setInterval 返回的计数器的引用,确保在点击暂停按钮的同时,也能用 window.clearInterval 停止计时器。那么,这个保存计数器引用的最合适的地方,就是 useRef,因为它可以存储跨渲染的数据。代码如下:
1 | import React, { useState, useCallback, useRef } from "react"; |
使用 useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的,这也是 useRef 区别于 useState 的地方。
除了存储跨渲染的数据之外,useRef 还有一个重要的功能,就是保存某个 DOM 节点的引用。我们知道,在 React 中,几乎不需要关心真实的 DOM 节点是如何渲染和修改的。但是在某些场景中,我们必须要获得真实 DOM 节点的引用,所以结合 React 的 ref 属
useContext:定义全局状态
React 提供了 Context 这样一个机制,能够让所有在某个组件开始的组件树上创建一个 Context。这样这个组件树上的所有组件,就都能访问和修改这个 Context 了。那么在函数组件里,我们就可以使用 useContext 这样一个 Hook 来管理 Context。
useContext 的 API 签名如下:
1 | const value = useContext(MyContext); |
正如刚才提到的,一个 Context 是从某个组件为根组件的组件树上可用的,所以我们需要有 API 能够创建一个 Context,这就是 React.createContext API,如下:
1 | const MyContext = React.createContext(initialValue); |
这里的 MyContext 具有一个 Provider 的属性,一般是作为组件树的根组件。这里我仍然以 React 官方文档的例子来讲解,即:一个主题的切换机制。代码如下:
1 | const themes = { |
看到这里你也许会有点好奇,Context 看上去就是一个全局的数据,为什么要设计这样一个复杂的机制,而不是直接用一个全局的变量去保存数据呢?
答案其实很简单,就是为了能够进行数据的绑定。当这个 Context 的数据发生变化时,使用这个数据的组件就能够自动刷新。但如果没有 Context,而是使用一个简单的全局变量,就很难去实现了。
不过刚才我们看到的其实是一个静态的使用 Context 的例子,直接用了 thems.dark 作为 Context 的值。那么如何让它变得动态呢?比如说常见的切换黑暗或者明亮模式的按钮,用来切换整个页面的主题。事实上,动态 Context 并不需要我们学习任何新的 API,而是利用 React 本身的机制,通过这么一行代码就可以实现:
1 | <ThemeContext.Provider value={themes.dark}> |
可以看到,themes.dark 是作为一个属性值传给 Provider 这个组件的,如果要让它变得动态,其实只要用一个 state 来保存,通过修改 state,就能实现动态的切换 Context 的值了。而且这么做,所有用到这个 Context 的地方都会自动刷新。比如这样的代码
1 | // ... |
Hooks与生命周期的比较
生命周期的方式是把组件的创建更新看作一条流水线,你可以在这个流水线上做些什么。而Hooks的方式则是,当状态变化了,要做什么。
如何组织React项目结构
软件复杂度的根源:复杂的依赖关系
我们经常会说,某个项目看上去好复杂。那么这个“复杂”,到底该怎么定义呢?如果仔细思考就会发现,当某个功能需要层层嵌套的模块依赖,那么即使开发时觉得思路很顺,但是自己再回头去看,或者要让别人理解某个功能实现,就不得不去翻阅很深的调用链。这就是让你觉得复杂的直接原因。那么我们可以这么说,软件复杂度的根源完全来自复杂的依赖关系。
在 components、actions、Hooks 等文件夹下,再按照功能进行分类。而这个分类的做法呢,经常是按照技术功能进行进一步划分,比如 table、modals、pages 等。这种做法其实会增加项目结构的复杂度,开发起来也很不方便,主要体现在两个方面。
一方面,对于一个功能,我们无法直观地知道它相关的代码散落在哪些文件夹中。比如内容管理系统中的分类功能,可能有列表、下拉框、对话框、异步请求逻辑等,它们都在不同的文件夹中。
另一方面,开发一个功能时,切换源代码会非常不方便。比如你在写分类列表功能时,就需要在组件、样式文件、action、reducer 等文件之间频繁地来回切换。而且,如果项目很大,那么你就需要展开很长的树结构,才能找到相应的文件,或者借助文件搜索去导航。不过,文件搜索导航的前提是,你还需要对整个功能的逻辑非常了解,知道有哪些文件。
产生这种开发难度的本质就在于,源代码没有按照业务功能组织在一起,而是从技术角度进行了拆分。所以呢,对于文件夹的组织,我们一定要按领域去组织源代码。一个与领域相关的文件夹,就类似于刚才讲的第一个场景,自身包含了自己需要的所有技术模块,这样无论是理解代码实现,还是开发时切换导航,都会非常方便。
可以看到,整个应用至少包含了文章管理、评论、分类、用户等模块。首先我们知道,一个 React 应用,一定是由一些技术部件组成的,比如 components、routing、actions、store 等,图中我用不同的颜色对这些部件进行了区分。但是呢,如果我们将这些技术部件分散到不同的领域文件夹中,而每个领域文件夹都有自己的 compoents、routing、actions、store 等。这样的话,每一个文件夹就相当于一个小型的项目,包含了与自己相关的所有源代码,就便于理解和开发。