React 新特性 Suspense 和 Hooks

在去年的 React Conf 上,React 官方团队对 Suspense 和 Hooks 这两个未来版本中的新特性进行了介绍,随着 React v16 新版本的发布,这两个特性也逐步进入到了我们日常使用中,本文将对带你快速了解这两个新特性,以了解 React 的发展趋势及这些新特性对 React 编码方式的影响。

背景

在开始介绍 Suspense 和 Hooks 之前,我们需要先对 React v16 版本的背景做一些了解,以简单理解当前版本 React 的工作原理。

Fiber

在引入 Fiber 之前 React 采用的是同步渲染机制,即在组件树建立或者发生更新时,整个过程是同步不可中断的。在一个 React 的应用中,应用的渲染/更新会触发一段连续时间的 JS 执行,这期间 JS 阻塞布局、动画等其他工作。随着应用规模的扩大(组件数量的增长),所需的占用时间也将越来越长,这就导致应用可能出现掉帧、延迟响应(如 input 输入延迟、点击响应延迟等)等较差的交互体验。

在 v16 版本中,React 对其核心算法进行了大的改变,即使用了 Fiber 进行了重写。希望通 Fiber 来改变这种执行时长不可控的现状,进一步提升交互体验,使应用可以更加流畅

简单来看,Fiber 就是把渲染/更新过程拆分成小的任务块,再通过合理的调度机制来控制执行这些任务块。在这种机制下,整个任务将可被暂停,复用及终止,这样以来就可以实现增量渲染(将任务拆分后,匀到多帧执行),而不像之前需要连续执行,同时可以给与不同任务不同优先级,允许任务插队。

从整体看虽然整个渲染/更新过程的工作量并没有减少,但由于有了任务优先级支持,我们在使用体验上可以减少很多延迟响应的情况,让应用感觉上更加流畅。

Fiber 的原理及详细执行过程可参考这个视频,本文所需背景中你只要知道引入 Fiber 之后的版本(准确说是要等到 Concurrent Rendering 开启之后的版本),React 的渲染/更新将过程不再是不能终止的了,一些任务将可能被打断并多次执行。

Render Phase & Commit Phase

在 Fiber 架构下, React 的渲染/更新过程被划分成为了两个阶段:Render Phase 和 Commit Phase,以划分哪些任务可以中断:

阶段
  • Render/Reconcile Phase: 此阶段会找出更新前后差异

因为这个阶段多是一些纯计算工作,可以计算一会儿保留结果一会儿接着算,所以其中任务是可拆分中断的。

  • Commit Phase: 此阶段会根据上一阶段找出的差异进行 DOM 更新

DOM 更新对真实 DOM 产生了操作,虽然看起来操作任务也是可以拆分,但这样可能造成 DOM 实际状态与内部维护状态不一致,还可能影响用户体验。同时 DOM 更新的耗时占比相比差异计算及生命周期函数耗时其实并不大,所以拆分的意义并不大,因此这个阶段任务不能中断。

两个阶段以生命周期中 render 函数为分界,render 以及 render 之前的生命周期都属于 Render Phase,render 之后的生命周期属于 Commit Phase。

因为 Render Phase 是可以被中断的,同时因高优先级任务插入造成的中断会使得当次任务被完全终止放弃(后在合适时机重新执行),所以其中的生命周期函数可能会被多次调用,因此我们不应该在 Render Phase 的生命周期函数里编写含有副作用(如数据获取、订阅或手动操作 DOM 等)的代码。

生命周期架构

在 v16 之前版本的 React 主要生命周期函数如下图:

旧生命周期

可以看到对应至 Render Phase 中的是下列生命周期函数:

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render

其中 shouldComponentUpdaterender 中我们一般不会编写含有副作用的代码,而其他的三个生命周期函数则较难保证开发者不会在其中执行一些副作用操作。这些生命周期函数在 Fiber 架构下(Concurrent Rendering 开启之后的版本)可能会被多次执行,所以其中包含的副作用也可能会被多次执行。

为了尽可能避免开发者在这三个生命周期函数中编写副作用代码,React 在 v16 版本对生命周期函数做出了调整,移除掉了这三个生命周期,加入了新的生命周期 getDerivedStateFromProps

image

静态生命周期函数

乍一看好像除了合并了生命周期函数外和之前并没有太大差别,但查阅文档便可以看到新加入的 getDerivedStateFromProps 是个静态(static)方法:

static getDerivedStateFromProps(props, state)

该方法在调用 render 之前调用,并且在初始挂载及后续更新时都会被调用,它应返回一个对象来更新 state

在这样一个静态的方法中,我们不能在其函数体内访问到 this,也就限制了我们很多操作(如 setState、实例方法调用等),执行副作用变得较为困难。官方的意图也很明确,就是不要再在这个生命周期中编写含有副作用的代码。

错误边界

在前端应用中,部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React v16 引入了一个新的概念 —— 错误边界(Error boundaries)。错误边界也一个 React 组件,它可以捕获子组件中的错误,并可借助 state 处理,展示降级 UI。

如果一个组件至少定义了下面两个新的生命周期函数中的一个,那它就成为一个错误边界。

static getDerivedStateFromError(error)
componentDidCatch(error, info)

这两个新的生命周期函数也是在 React v16 引入的。getDerivedStateFromError() 被设计用于捕获 Render Phase 中的异常,会在 Render Phase 调用,因此不希望其中产生副作用,所以也被设计为静态方法。相对的 componentDidCatch() 用于在 Commit Phase 阶段捕获异常。


了解了以上这些背景后,我们来看 React 新版本的这两个新特性:

Suspense

Suspense 主要是为了解决两个问题:

  • 代码分割
  • 数据获取

在此之前,社区对这两个问题已经有了五花八门的实现,React 也只专注于 view 层本身,并不关注代码打包,数据获取这些事情。但在 v16 中,React 团队给出了官方的解决方案 —— Suspense。

代码分割

代码分割是由 Webpack 这类打包工具支持的一项技术,通过代码分割能够将代码切割为多个包并在运行时动态加载。这能够帮助我们实现内容的“懒加载”,可以显著地提高应用的性能。

当前代码分割的最佳方式是通过 ECMAScript 提案中的动态 import() 语法,该语法返回一个 Promise,当 Webpack 解析到该语法时,会自动进行代码分割。

import { add } from './math';
console.log(add(16, 26));

使用后:

import("./math").then(math => {
  console.log(math.add(16, 26));
});

在 React 中,我们可以使用 React.lazy 函数像渲染常规组件一样使用动态引入的组件。同时我们需要配合 React.Suspense 来实现加载时的降级,fallback 将在加载过程中进行展示。

如果模块加载失败(如网络问题),会触发一个错误。你可以通过错误边界来处理。

// 该组件是动态加载的
const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
  	 <MyErrorBoundary>
      // 显示 <Spinner> 组件直至 OtherComponent 加载完成
      <React.Suspense fallback={<Spinner />}>
        <div>
          <OtherComponent />
        </div>
      </React.Suspense>
    </MyErrorBoundary>
  );
}

更详细的信息你可查看官网代码分割指南

数据获取

使用 Suspense 进行数据获取至今还没有一个正式的 API,但其大致的方式我们可以从当前非正式的版本看到。unstable_createResource 类似 React.lazy,接收一个返回 Promise 的函数作为参数,然后在使用到的地方使用 React.Suspense 包裹处理降级。

// React Cache for simple data fetching (not final API)
import { unstable_createResource } from 'react-cache';

// Tell React Cache how to fetch your data
const TodoResource = unstable_createResource(fetchTodo);

function Todo(props) {
  // Suspends until the data is in the cache
  const todo = TodoResource.read(props.id);
  return <li>{todo.title}</li>;
}

function App() {
  return (
    // Same Suspense component you already use for code splitting
    // would be able to handle data fetching too.
    <React.Suspense fallback={<Spinner />}>
      <ul>
        {/* Siblings fetch in parallel */}
        <Todo id="1" />
        <Todo id="2" />
      </ul>
    </React.Suspense>
  );
}

// Other libraries like Apollo and Relay can also
// provide Suspense integrations with similar APIs.

整个过程很有趣的一点是完全以同步的写法实现了异步数据获取。

原理

从解决两个问题的示例中你大概也可以猜到它的原理和 Promise 有关。其真正实现原理也并不复杂,简单来说就是通过 throw 一个 Promise,然后 React.Suspense 通过上文中处理子组件错误的生命周期函数捕获到它,在它没有 resolve 时渲染 fallback,resolve 后渲染实际内容。同时该机制内部还做了缓存处理,如果包含缓存数据就不执行 throw,以防止多次重复副作用的执行。

原理

Hooks

初窥

React 中 Hook 是指一些可以让你在函数组件里“钩入” state 及生命周期等特性的函数。简单来看,Hooks 提供了可以让我们在函数组件中使用类组件中如 state 等其他的 React 特性的一种方式。

我们先来看官方的几个基础 Hook 示例:

State Hook

import React, { useState } from 'react';

function Example() {
  // 声明一个叫 “count” 的 state 变量。
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

这个是一个计数器的示例。当你点击按钮,计数器值就会增加。示例中 useState 就是一个 Hook,通过它,我们给一个函数组件添加了 state。

useState 返回一对值:当前状态和用来更新它的函数,你可以在其他地方调用该函数更新状态,类似类组件的 this.setState,但不会自动合并新旧 state。

你可以在一个组件中多次使用 State Hook,同时得益于其数组解构的语法,你可以为不同 state 变量取不同的名字。

function ExampleWithManyStates() {
  // 声明多个 state 变量!
  const [age, setAge] = useState(42);
  const [fruit, setFruit] = useState('banana');
  const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
  // ...
}

Effect Hook

useEffect 就是一个 Effect Hook,给函数组件提供了执行副作用(如数据获取、订阅或手动操作 DOM 等)的能力。它和类组件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount 具有等效的用途,只不过被合并成了一个 API。

默认情况下,React 会在每次渲染后调用副作用函数(包括第一次渲染时),同时 useEffect 还可以通过返回一个函数来指定如何“清除”副作用。例如,在下面的示例组件中使用 useEffect 来订阅好友的在线状态,并通过取消订阅来进行清除操作:

import React, { useState, useEffect } from 'react';

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);

    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

每次重新渲染,React 都会生成新的 effect 替换掉之前的,即执行上一个 effect 放回的清理函数后执行新的 effect。如示例中组件可能会产生如下的操作序列:

// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange);     // 运行第一个 effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange);     // 运行下一个 effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange);     // 运行下一个 effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // 清除最后一个 effect

但示例代码中,假如传入 props 中除 friend.id 外会有其他参数的更新,每次组件更新写软也会触发 effect 的多次执行。在某些情况下,这样的多次副作用操作会导致性能问题或者我们不希望这么做,这时可以通过传递数组给 useEffect 可选的第二个参数来跳过某些某些更新时 effect 的执行。

useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // 仅在 props.friend.id 发生变化时,重新订阅

如果只想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数。这就告诉 React 该 effect 不依赖于组件 props 或 state 中的任何值,即它永远都不需要重复执行。

另外和 useState 一样,你也可以使用多个 useEffect,后面我们会看到这种使用多个 Hook 来分离的好处。

动机

Hooks 不仅仅是为了丰富函数组件的功能才引入的,实际上它解决了一些更深层次的问题。回想你在使用 React 编写应用的过程中,应该都遇到过下面三个问题:

  • 难以理解 class
  • 复杂组件难以理解
  • 组件间状态逻辑难以复用

这也是 React 官方引入 Hooks 的几个主要动机,我们分别来看:

难以理解 class

在之前学习 React 过程中,class 成为了一大屏障。你必须去理解 JavaScript 中 this 的工作方式,要时刻记得绑定事件处理器,而由此产生的代码实际上是非常冗余的。同时 class 给组件预编译、代码压缩、热加载等工作带来了很多困难。

class MarkdownEditor extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: 'Hello, World!' };
    // 绑定 this
    this.handleChange = this.handleChange.bind(this);
  }

  handleChange(e) {
    this.setState({ value: e.target.value });
  }

  render() {
  	return <input value={this.state.value} onChange={this.handleChange} />;
  }
}

因此 React 选择用 Hook 来拥抱函数组件。从 React 概念及设计哲学上讲,组件也一直更像函数。

同时相比 class,我们可以更直接的使用 props, state,context,refs 以及生命周期,并有了更强大的方式来组合他们。对于生命周期函数调用的设计,Hook 更加简洁明了,同时可以尽可能避免在 Render Phase 中执行副作用。

复杂组件难以理解

在我们日常代码编写维护中,组件起初很简单,但是逐渐会被状态逻辑和副作用充斥。每个生命周期函数常常都包含一些互不相关的逻辑。例如,组件常在 componentDidMountcomponentDidUpdate 中获取数据,但在同一个 componentDidMount 中可能也包含很多其它的逻辑,如建立事件监听,并且之后需在 componentWillUnmount 中清除。

这些相互关联且需要对照修改的代码被拆分在不同地方,而那些互不相关的代码却在同一个方法中组合在一起,或者说每个生命周期函数都包含某个业务逻辑的一部分,每个业务逻辑又被分散在每个生命周期函数中。这样使得代码很难阅读并且非常容易产生 bug。

类组件

像图中类组件,document.title 修改逻辑与 "resize" 事件监听逻辑被揉在一起,并且每种逻辑又被生命周期函数所分离,为阅读修改带来了很大困难。

函数组件

而使用 Hooks 改写之后,两部分逻辑都被各自组合在一起,互不干扰,实现了关注点分离,使得代码更加容易理解、维护。

组件间状态逻辑难以复用

在没有 Hooks 之前,我们处理组件间状态逻辑复用(如把组件连接至 store)的情况时,通常的两种方式是使用高阶组件Render Props

我们通过一个简单示例看一下这两种方式,示例中我们需要共享的状态逻辑为:通过上文中用到的 ChatAPI,获取某个 friendId 的在线状态 isOnline

高阶组件

这是高阶组件对该逻辑复用的实现,我们会对原组件进行一层包裹,并将需要的状态注入其 props。

function withFriendStatus(WrappedComponent) {
  return class extends Component {
    state = {
      isOnline: null,
    }

    handleStatusChange = ({ isOnline }) => this.setState({ state });

    componentDidMount() {
      ChatAPI.subscribeToFriendStatus(this.props.friend.id, this.handleStatusChange);
    }

    componentWillUnmount() {
      ChatAPI.unsubscribeFromFriendStatus(this.props.friend.id, this.handleStatusChange);
    }

    render() {
      return <WrappedComponent {...this.props} isOnline={this.state.isOnline} />
    }
  }
}

在使用时,我们调用函数进行组件包裹,也可以借助装饰器来完成。

@withFriendStatus
class FriendListItem extends Component {
  // this.props.isOnline
}

这样我们在每个需要用到 FriendStatus 的组件外层都使用该高阶组件包裹,就可以在组件内拿到所需的状态,但这样做有几个缺点:

  • 组件中属性难以溯源,并且存在属性覆盖的问题

设想我们的原始组件,先后通过高阶组件-A、高阶组件-B、高阶组件-C……的包裹,最后生成了新的组件,我们得到的新组件中会有很多与原组件不同的 props,但是我们仅通过新组件,并不能直观知道某个 props 的来源。

另外,如果有高阶组件同时修改了原组件的某个同名属性,那么该属性会被后一个高阶组件覆盖,可能使得前一个高阶组件失效。当我们使用一些第三方高阶组件时必须保证包裹链上的属性不会被覆盖,这点非常不利于高阶组件的分享。

  • Wrapper Hell

高阶组件改变了当前组件的层级结构,当我们使用了多层高阶组件时,在 React Dev 工具中看到的结构将会变得非常深,这会加大调试的难度。

HOC
Render Props

Render Props 方案使用了一个接收函数的 prop 解决了逻辑复用,状态将通过该函数参数返回,同时该函数将继续渲染原组件内容。

class FriendStatusProvider extends Component {
  state = {
    isOnline: null,
  }

  handleStatusChange = ({ isOnline }) => this.setState({ state });

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(this.props.friend.id, this.handleStatusChange);
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(this.props.friend.id, this.handleStatusChange);
  }

  render() {
    return this.props.children(this.state.isOnline);
  }
}

在使用 Render Props 时,我们只需要将原组件的 render 内容移至逻辑复用组件的 render prop 属性的函数中,此时就可以拿到所需的状态。

class FriendListItem extends Component {
  render() {
    return (
      <FriendStatusProvider friend={this.props.friend}>
        {isOnline => (
      	   // 原有组件 render 内容
        )}
      	</FriendStatusProvider>
    );
  }
}

Render Props 对比高阶组件,解决了属性难以溯源,以及属性覆盖的问题,但同样这种方案也改变了原有组件的层级结构,可能会带来 Wrapper Hell 的问题。同时由于其写法直接包裹了原组件的 render 部分,在使用多层 Render Props 时也会使编码过程中产生 Wrapper Hell,加大了阅读难度。

RenderProps
自定义 Hook

在引入 Hooks 之后,我们有了新的方案来解决状态逻辑复用的问题。

回想当我们想在两个函数之间共享逻辑时,我们会把它提取到第三个函数中。而函数组件和 Hook 都是函数,所以也同样适用这种方式。我们可以将要复用的逻辑提取到一个函数中,它被称作自定义 Hook。

自定义 Hook 是一个函数(其名称应以 “use” 开头,以方便辨识),函数内部可以调用其他的 Hook,以下是上文需求的自定义 Hook 实现:

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

在使用自定义 Hook 时也和调用函数相同,它和将自定义 Hook 中代码拷贝至调用处的执行结果相同。

function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);

  return (
    // ...
  );
}

相比原有的解决方案,自定义 Hook 没有改变原有组件层级结构,避免了 Wrapper Hell,同时轻量、完全独立,易于复用及社区共享。

总结

Hooks 的出现使得函数组件的功能更加完善,且可以更加方便实现逻辑的分离和复用。

更多 Hooks 相关信息你可以查看官网: 规则APIFAQ

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券