对React Hooks的一些思考

1,255 阅读12分钟
原文链接: zhuanlan.zhihu.com

React Hooks正式公布也有一长段时间了,在选择第一时间接受“总之这就是未来了,你不陪跑也得陪跑”的现实之后,逐渐尝试着在脑内对一些既有的代码基于Hooks去进行重构,也阅读了不少社区里的讨论。

在社区中,大部分布道者都提到了诸如“过于冗繁的组件嵌套”、“与内部原理的更亲密接触”、“比原先更细粒度的逻辑组织与复用”等优势。而在此之外,基于我自己的一些经验,也在更学院派的维度上有一些见解,权当补充。

总体感受

首先,受Suspence和Hooks的影响,函数组件不再是纯粹的数据到视图的映射这已然成为既定的事实,React团队决定走上这一条路,那必然已有权衡,也必然不会回头。对于使用者而言,更为紧要的是在当下思考一下“函数组件将被赋予怎么样的全新定义”这一问题。

在我看来,暂先撇开Suspence这个对函数组件影响比较明确的东西之外,在Hooks的加持之下,组件将在原有的“输入到输出的映射逻辑”这一严格定义之外,逐渐地追加上“逻辑所依赖的前置条件”这一内容。

const WordCount = () => {
    const [text, onChange] = useState('');

    return (
        <div>
            <TextBox value={text} onChange={onChange} />
            <span>{text.length} characters</span>
        </div>
    );
};

如果除去useState的一行,将textonChange作为组件的props输入,这一组件就是完美的“输入到输出的映射逻辑”,即将2个属性映射为一组React元素。

而在基于Hooks的实现之下,useState这一行本质上是在声明后半部分(return)“依赖于一个状态”这一事实。

除此之外,诸如useContextuseEffect等,其调用也都是在一个函数组件中声明整个渲染过程的前置条件,需要一个Context,或者需要一些副作用。

基于这一考虑,现在的我应该会期望未来函数组件在使用Hooks时,统一将Hooks的调用放在函数的开头部分,随后紧跟一个纯函数组件的渲染逻辑,即一个组件的逻辑拆分为:

const FunctionComponent = props => {
    // 对所有Hooks的调用,声明前置条件

    // 对props及hooks提供的内容的运算处理,数据加工

    // 将数据转变为JSX并返回
};

我想尽可能地遵守这一顺序是能够指导高度可维护的组件的设计和实现的。当出现需要在第2步之后再调用Hooks的场景时,应当立即思考组件的拆分和粒度是否合理,这往往是组件应该进一步拆分成更多细粒度组件的信号。

状态管理

React Hooks最为经典的内置函数即useState,它会返回一个Tuple,结构为[value, setValue]

在实际操作中,我们现有使用的自研框架react-kiss(后续再专门写个文章来介绍)的思路与其几乎如出一辙:

import {withTransientRegion} from 'react-kiss';

const initialState = {
    text: ''
};

const workflow = {
    onChange(text) {
        return {text};
    }
};

const WordCount = ({text, onChange}) => (
    <div>
        <TextBox value={text} onChange={onChange} />
        <span>{text.length} characters</span>
    </div>
);

export default withTransientRegion(initialState, workflow)(WordCount);

除去我们无法让状态本身直接是一个字符串,以及API的使用略为繁杂外,其模式并没有什么区别。甚至基于withTransientRegion并且没有使用到react-kiss的异步能力的组件,都能非常简单地使用withStatewithReducer进行重构,不费额外的成本。

我认为在useState的冲击之下,绝大部分“正常”使用React的State的开发者应当反思以下几个问题:

  1. 为何没有将“状态”与“变更状态的逻辑”两两配对,用更好的代码结构来组织它们。
  2. 为何没有将状态进行更细粒度的拆分,没有联动关系的状态放到不同的组件中单独管理,而是习惯性地使用一个大的状态,以及多处setState进行部分状态的更新。
  3. 为何没有将状态的管理与视图的渲染进行隔离,把一个带有复杂的render实现的类组件拆分为一个“单纯管理状态的类组件”和一个“实现渲染逻辑的纯函数组件”,并让前者的render方法直接返回后者。

useState正是将以上三个理论合而为一,用一个非常简洁的API表现出来的经典设计:

  1. valuesetValue配对,后者一定影响前者,前者仅被后者影响,作为一个整体它们完全不受外界的影响。
  2. 在几乎所有的示例中,都推荐value是一个非常细粒度的值,甚至可以是一个字符串之类的原子值(在原本的React中使用非namespace型的对象作为state并不被提倡)。鼓励在一个函数组件中多次使用useState来得到不同维度的状态。
  3. 在调用useState之外,函数组件依然会是一个实现渲染逻辑的纯组件,对状态的管理已经被Hooks内部所实现。

在我们的团队中,不定期地会相互强调一个原则:有状态的组件没有渲染,有渲染的组件没有状态。在现在回过头来看,这个原则会为我们后续向Hooks的迁移提供非常大的便利。

生命周期

useEffect也是当下聊得最多的Hooks之一。官方在推出这一API时,也告诉了大家一个事实,React团队将倾向于把componentDidMountcomponentDidUpdate不作区分。

事实上,React团队早就有了这一倾向,并通过先前版本的API向开发者传达了这一信号,那就是用getDerivedStateFromProps替代componentWillReceiveProps

componentWillReceiveProps的时代,组件的state其实根据生命周期阶段的不同,是有2个不同的计算方法的:

  1. 在mount之前,推荐在构造函数中通过props参数来计算state,并直接赋值为this.state
  2. 在mount之后,使用componentWillReceiveProps来计算并使用setState进行更新。

getDerivedStateFromProps作为一个静态方法,根本没有区别这两者,它是唯一的“通过props计算state”的入口。甚至为了将其统一,React团队ref="https://github.com/reactjs/rfcs/pull/6#discussion_r162865372">放弃了将prevProps作为这一方法的参数,在社区造成了不少的讨论。而我在与eslint-plugin-react讨论规则时有提出一个观点:

将组件的状态分为2部分,一部分为自己生成自己管理的自治状态(owned),另一部分为由props计算得来的衍生状态(derived)。在初始化状态时,仅初始化自治状态,将原生状态赋为null,并在getDerivedStateFromProps中再进行初始化(即便在构造函数中可以完成衍生状态的计算)。

我至今依然相信上述说法是React团队通过getDerivedStateFromProps这一API想传递给开发者的思想,即不要再区分组件是否已经mount,使用统一的API来统一地进行状态管理。

而在这一思想的引导下,在我们的产品中,对于最为常见“在生命周期中请求数据”这一场景,我们是这么处理的:

import {connectOnLifeCycle} from '@baidu/etui-hocs';
import {connect} from 'react-redux';
import {property} from 'lodash';
import {compose} from 'recompose';
import {fetchMessageForUser} from '../../actions';

const Welcome = ({message, username}) => (
    <div>
        Hellow {uesername}: {message}
    </div>
);

const connects = [
    {
        grab: property('message'), // 没数据的时候准备加载
        selector: property('username'), // 参数变化的时候进行加载
        fetch(username, {onRequestMessage}) {
            return onRequestMessage(username);
        }
    }
];

const mapStateToProps = state => ({username: state.currentUser.username});

const mapDispatchToProps = {
    onRequestMessage: fetchMessageForUser
};

const enhance = compose(
    connect(mapStateToProps, mapDispatchToProps),
    connectOnLifeCycle(connects)
);

export default enhance(Welcome);

可以看到,我们是将与生命周期相关的逻辑交给了connectOnLifeCycle这一HOC,从而保持组件是一个纯粹的渲染过程,通过纯函数组件的形式实现。而对于connectOnLifeCycle我们并没有定义mount与update的区别,而是用3个属性来声明一个外部的依赖:

  1. 数据在哪里(grab):当数据有的时候,就没必要发起请求,这是一个很直接的逻辑。
  2. 数据与什么相关(selector):可以认为这是“获取数据的参数”,仅当参数发生变化时,请求才会被重新发起(当然数据不存在依然是前提)。
  3. 怎么发起请求(fetch):真正的请求逻辑。

对于一个偏向信息管理的系统,往往我们只要只要关注好这3个内容,就能快速地完成功能模块的开发,而mount还是update完全是不重要的。

工具支持

这是我对React Hooks最为担忧的一点。虽然社区里对Hooks的支持普遍来自于“原有的React组件嵌套太深”这一观点,但是于我而言,一定的组件嵌套实际是有很好的正面作用的。

以我们用于处理逻辑分支的react-whether库为例,其在最终渲染的组件树中是这样的结构,可以通过React Devtools看到:


我们能从Devtools里迅速地看到,组件进入了IfElseMode(相对应的还有一个SwitchMode),且条件没有命中(matches={false}),最终渲染出了<a>更新</a>这样的结构。

在我看来,在组件树上留下适当的信息,借助于Devtools进行追踪,是React开发中很好的一种调试模式。正如我们经常会用console.log来定位信息一样,其本质是将一些过程中短生命周期的信息(随着函数调用栈而快速消失)固化下来,无论是通过控制台还是通过组件树给予开发者调试时的支持。

对于更复杂的场景,只要逻辑的分层合理,使用HOC或render prop形式进行嵌套,在组件树中可以让开发者快速地通过检查每一层的props快速将问题定位缩小至很小的一个范围内,随后基于纯函数的高可测性来进行问题的复现和修复,同时能够固化为单元测试的用例。

而Hooks的诞生,无疑在释放一种信号,让开发者通过一个函数组件,使用不同的hook声明其前置依赖,将这些信息都压缩在一起,并且(至少当前)在Devtools中将不会保留任何痕迹。这样的结果是,也许在开发的过程中是非常痛快而流畅的,但凡遇到线上相对不易复现的BUG,其跟踪和调试过程将表现出成倍的痛苦。

API设计

我一直认为,React的API设计是非常强力的,甚至在我见过的各种团队之中,仅有微软能在API设计上说稳稳地强过React。React的API设计完全是语言级的模式,而不是简简单单的框架的玩法。这重点体现在几个方面:

  1. 对API的废弃有一整套成熟的流程。并不会在一个大版本中就立即抛弃N多的API,而是通过标记UNSAFE_、使用StrictMode等形式,给开发者一个平滑的过渡。这种跨越大版本的API废弃模式,我只在语言级框架如.NET、Java中见过,尚未在任何一个视图层的框架中见识。
  2. 不该用而又不得不存在的API,会用尽办法恶心调用者。最为经典的dangerouslySetInnerHTML这一属性,除了加一个dangerously这么显眼的前缀外,还非得让它的值是一个对象,对象里还要有一个__html这样逼死强迫症的属性名。凡是对代码美感有一些些追求的开发者,都会想方设法让这东西消失在自己的眼前。
  3. 所有的API都具备非常强的最佳实践引导性。比如getDerivedStateFromProps这一方法,硬是给做成了静态的,让开发者用不到this,也自然很难胡乱地在里面做出有副作用的事情来。现比如setState使用callback而不是Promise,并且官方义正言辞地拒绝了转为Promise实现。现在看到useState的出现,再回过头去看setState一直控制着让开发者尽量不使用callback是多么明智。
  4. API的发布具有很好的承接性。先是componentDidCatch这一API,提供了Error Boundary的概念并让广大开发者接受,随后才是Suspence这个非常具备破坏力的炸弹,实际则是通过Error Bounday和Promise的类型判断来完成。这给了开发者接受和准备的时间,不至于因为一下子连带的太多概念而产生更强烈的抵触情绪。
  5. 完全突破原有理论的API可以在没有大版本的情况下发布。从Suspence到Hooks,这两个API的发布确实给了我很大的震憾,而这震憾有很大一部分来自于它们居然只在一个小版本上就发布出来了,可见React原本简洁的API有着多大的包容性。

正是因为有这么强大的API设计能力,React在引入Fiber这种几乎破坏性的底层变化之际,几乎没有在社区掀起反对的波浪。事实上有很多对React使用不当的场合是没有办法无缝地迁移到异步Fiber之上的,而15版本的React本身是可以搞出很多使用不当的场合的。依靠着自身API强大的最佳实践引导能力,Fiber的推进到开发者的适配几乎没有出现过大的失败案例,这实属不易。