手记

函数的“复辟”之路——理解React Hooks的使命

相比于 Angular 的迅速陨落和 Vue 的快速崛起,React 则按照自己的节奏,不紧不慢地进化着,始终吸引着大量的前端开发者。与 Vue 体系从一开始就建设了超低入门成本的完善解决方案不同,React生态显得略微零散一些,只有有足够经验的工程师才能在数个相互搭配工作的工具库上游刃有余,而作为React的开发团队,Facebook仅仅提供了一个简单的 create-react-app 脚手架工具,连 redux、react-router 或者 less 都不提供支持,可以说简约至极。这也导致了大量初级工程师在面对 React 时的不知所措,进而更愿意转投 Vue 的怀抱。

一个好的工具绝对不会缺少能操控它的人,更不会缺少真正应发挥其强大能力的场景。React 始终致力于提供极致的数据到视图的转换解决方案,为复杂 Web 应用提供最强大的渲染引擎。多种多样的需求,必然需要极其灵活的架构可塑性,因此,React 团队并没有去实现一个模式化的样板工程,因为他们知道,无论样板工程的功能如何丰富,都不可能满足所有的需求,高度整合只能满足一部分足够简单的应用场景,灵活组合才能生产任意维度的解决方案。所以 React 的野心更在于能够支撑大型高度复杂的 Web 应用,对于互联网领域常规的小型项目,则自然不在话下。

作为已经开源6年的老项目,React 近年来仍然不断在创新,挑战自我,比如 Fragment、Context、lazy、Suspense、Hooks,甚至把从一开始就存在的几个很重要的生命周期函数都废弃掉了。React 考虑的远远不是能不能用好不好用的问题,甚至都不完全是性能的问题,而是应不应该这样用。优雅而合理的 API 设计始终是 React 的一道靓丽的风景,以至于众多第三方工具框架纷纷效仿,都以与 React 兼容为荣。

但是显然对 React 的模仿也仅仅能停留在表面上,React 内核使用的大量算法、科学模型的设计是不可能被完整模仿来的,比如我们看到 Fiber、Concurrent Mode 已经或者即将为我们带来的性能上的提升,都是远远领先于业界的。因此毫不夸张地说,React 引领了前端领域的技术研发思路与方法

2018年,React 提出来新的生命周期 API,并废弃掉了一批容易被错误使用的旧 API,但我们仍然可以维护大量的类组件。现在,React 甚至又要拿类组件开刀,使用一种叫做 Hooks 的编程方法来革类组件的命。这从侧面也印证了 React 从来没有对手,唯一的对手就是自己这样的事实,稳定、高性能从来不是最终目的,优雅的软件架构才是更高的抽象。

这篇文章我们就来聊一聊这个 Hooks 新特性究竟给我们带来一种怎样的体验,以及它是如何被设计出来的,这可能是今后一到两年内 React 的最大变化,极有可能以领先的姿态带动一批类似解决方案的转型。

先来看下面这样一段普普通通的组件声明:

class App extends Component {
  state = {
    user: null
  }

  onScroll = () => {
    
  }

  componentDidMount() {
    document.addEventListener('scroll', this.onScroll, false);
    
    fetch('/user').then(res => res.json()).then(user => this.setState(user));
  }

  componentWillUnmount() {
    document.removeEventListener('scroll', this.onScroll, false);
  }

  render() {
    const { user } = this.state;
    return (
      <div>
      	{
          user && (<div>{ user.name }</div>)
        }
      </div>
    );
  }
}

我们在程序挂载到页面上后立即做了两件事情,一是绑定事件,二是访问异步请求,更新 state。那么在新的 Hooks 架构下,这个组件会这样写:

function App() {
  const [user, setUser] = useState(null);
  
  userEffect(() => {
    document.addEventListener('scroll', this.onScroll, false);
    return () => {
      document.removeEventListener('scroll', this.onScroll, false);
    };
  }, []);
  
  useEffect(() => {
    fetch('/user').then(res => res.json()).then(user => setUser(user));
  }, []);
  
  return (
      <div>
      	{
          user && (<div>{ user.name }</div>)
        }
      </div>
   );
}

怎么样,是不是看着舒服多了,体现在下面三点:

  1. 与具体 user 字段绑定的专用 setUser 方法,不再需要封装成key-value传给 setState 的形式,调用简洁;
  2. 聚合了对 scroll 事件的的绑定解绑行为,降低了忘记做清理操作的风险;
  3. 对scroll事件的处理和发起异步请求的处理分开编写,隔离了不相干业务

这是我们仅仅从代码的表象来总结到的进步和优势。下面我们来正式认识一下 Hooks。

Hooks其实是一系列特殊的名字以use开头的函数,切入到组件执行的不同阶段来运行,进而发挥出类似生命周期函数的作用。上面我们看到的 useStateuseEffect 都是 Hooks,正因为它的这种切入的特性,我们不再需要 class 类声明组件,事实上,Hooks 只能运行在函数组件中。

过去 React 也存在着函数组件,不过严格来讲它们应该叫做无状态组件,因为不可以使用任何 state。现在有了 Hooks,函数组件不再必须无状态,还可以执行各种副作用逻辑,俨然已经替代了类组件的作用。我们现在开始,只能对组件划分为函数组件和类组件,不再存在“无状态组件”了。

新事物的诞生往往都是为了颠覆旧事物,Hooks 之所以被设计出来,正是因为类组件存在着一些弊端。在这里不得不佩服 React 团队对开发体验的极致追求和对问题鞭策入里的分析。

过去没有人认为使用类(class)来声明组件有什么问题,但是 React 团队却敏锐地觉察到了类的组织形式对于组件开发的不友好之处,比如难以复用状态逻辑、难以理解的复杂组件以及类结构本身的困惑性等等。

这些问题在之前没有人质疑过,但我们不能否认经过 React 团队的指出,它们确实是客观存在的。我们来看一下类组件都有哪些问题,大家会不会有恍然大悟的感觉。

由于类组件生命周期函数的天生割裂性,对于同一状态的操作往往分散在不同的函数之中。就比如刚才我们对 scroll 事件的使用:

  onScroll = () => {}
 
  componentDidMount() {
    document.addEventListener('scroll', this.onScroll, false);
  }

  componentWillUnmount() {
    document.removeEventListener('scroll', this.onScroll, false);
  }

这样的话,现在假设我们有多个组件都有这个逻辑怎么办呢?换句话说我们如何实现状态逻辑的分享呢?拷贝代码是最蛮力的做法,但显然有悖于基本的软件设计原则,我们应该不屑为之。

不是没有办法,比如类继承:

class Scrollable extends React.Component {
  _onScroll = (...args) => {
    this.onScroll(...args);
  }
 
  onScroll = () => {}
 
  componentDidMount() {
    document.addEventListener('scroll', this._onScroll, false);
  }

  componentWillUnmount() {
    document.removeEventListener('scroll', this._onScroll, false);
  }
}

class App extends Scrollable {
  onScroll() {}
  
  componentDidMount() {
    super.componentDidMount();
  }
  
  componentWillUnmount() {
    super.componentWillUnmount();
  }
}

但类继承有一个很明显的缺陷,JavaScript 不能实现多继承,如果 App 除了想复用 Scrollable 以外,还想复用另一个 Resizable,那么基本就是不可能达到的目的。

还有一种方法,高阶组件(Hoc):

function scrollable(Component) {
  return class Wrapper extends React.Component {
    ref = React.createRef()
  
    onScroll = (...args) => {
    	this.ref.current.onScroll(...args);
    }
 
    componentDidMount() {
      document.addEventListener('scroll', this._onScroll, false);
    }

    componentWillUnmount() {
      document.removeEventListener('scroll', this._onScroll, false);
    }
    
    render() {
      return <Child ref={this.ref}/>
    }
  };
}

class _App extends React.component {
  onScroll() {}
  render() {}
}

const App = scrollable(_App);

关于高阶组件的概念和写法本文不再冗述。

高阶组件至少有两种写法,上面是一种,另一种称之为 render props,无论哪一种,基本原理是一致的,都是封装。这种写法虽然也能够实现状态逻辑的分享,但是代价是增加了很多没有意义的组件层级,显得不够优雅,而且难以调试。

由此可见,我们有理由认为类组件不擅长分享状态逻辑

绑定事件和解绑事件本来应该是成对出现的,就好比 C 语言中的分配内存malloc和释放内存free,或者 C++ 中的构造函数和析构函数。但是由于分散在不同的位置,极容易造成不匹配,一般是忘记解绑、释放、析构,这轻则造成内存泄露,影响应用程序稳定性,重则造成业务逻辑混乱,完全无法使用。

类形式的 React 组件就是这样的形式,还是以上面那个为例:

  componentDidMount() {
    document.addEventListener('scroll', this.onScroll, false);
    
    fetch('/user').then(res => res.json()).then(user => this.setState(user));
  }

  componentWillUnmount() {
    document.removeEventListener('scroll', this.onScroll, false);
  }

有两个问题,一是对 scroll 事件的绑定和解绑分别位于 componentDidMountcomponentWillUnmount 两个生命周期函数中,当类似的事件绑定变多,则很容易遗漏对某个事件的解绑。这是因为这样的写法无法做到对特定事件的集中维护。

二是在同一个生命周期函数 componentDidMount中,有两个完全不相干的逻辑。从基本软件设计原则出发,对不相干逻辑的关注点不应该耦合。比如我们有另一个组件继承该组件,componentDidMount 中仅仅想绑定事件但是不想发起异步请求,这是很难以优雅的方式办到的。因此我们认为类组件很容易变得复杂起来,进而增加复用和维护难度。

刚刚的例子中,我是以类属性的形式来声明的 onScroll 函数:

  onScroll = () => {
    
  }

这本身也是一种无耐的做法,因为以类成员的形式来声明的话,事件函数响应时则无法关联到 this 上下文。当然也不能以bind的方式处理,因为句柄没有被保存,解绑是没有办法做到的。

这只是其中的一个困惑,与业务开发没有直接关联的还有代码体积优化、热更新等等,都会受到类结构限制而难以施展。


从上面这三个方面我们基本能看出来对类组件编写形式的吐槽并非吹毛求疵,确实在一线的开发工作中客观存在的。至少在我个人的工作生涯中,这些问题我都曾经遇到过,也并没有很好的优化思路。

相比于在类组件的一维时间线上部署各种业务逻辑,Hooks 将相关联的逻辑平行于时间线,然后在时间线的垂直方向上,部署任意数量个相对独立的逻辑,创造了第二维空间,不得不说很巧妙。这样类组件的三个问题都能够被解决了。

Hooks 只能在函数组件中使用,直接就强制避免了类结构对开发者、代码分析器等角色造成的困惑性。

当然,React 并没有计划去完全抛弃类组件,并且两种不同类型的组件也能够无缝接合,这意味着你的遗留项目直接能在新的 Hooks 环境中运行,不需要改变任何代码。这样现有的React生态就会得到完整的保留,同时提供了渐进增强的机会——你可以在下一次迭代周期中使用Hooks。

曾经的“无状态组件”由于没有“this”上下文的挂载,所以没有办法使用状态。在 Hooks 环境中,我们使用 React 提供的内置函数 useState 来声明任意个 state:

function App() {
  const [loading, setLoading] = useState();
}

第一眼看上去可能觉得有点突兀,useState 怎么知道返回 App 的状态?它又如何确定返回的就是 loading 这个状态?

函数式编程,特别是 JavaScript 语言中的函数式编程,往往都会有一种莫名其妙地阅读体验,这主要是因为,JavaScript 是单线程的,相比于多线程的好处是,在某个执行节点上,能够轻易地推断出其它执行流的状态。

useState 就是这么个原理,只要每个组件的进入和退出都被监控和记录,那么 useState 被调用之时就知道它在哪一个组件中了。如果是多线程的话,同时有多个组件在执行,useState就没办法推断它属于哪一个组件了,除非把 useState 直接挂载在这个组件的上下文中,那么这就变成了类结构。

从这一点我们也能看出来,Hooks 下的状态声明,其实就是把类组件状态声明所在的类上下文,拓展到了全局上下文,所有函数组件都成了“闭包”。这样不但可以使用可以顺利使用函数组件,还能更简洁地使用 state 和 setState。你看,我们有了专门针对于 loading 的 setLoading。

如果在一个组件中有多个 useState,那么怎么确定哪一次调用返回哪一个 state 呢?

这个问题很有意思,本来即时是在单线程环境下,React 也没有办法在同一个组件上下文中确定哪一次 useState 调用返回哪一个 state,毕竟你可以写出下面这样的代码:

function App() {
  if (someCondition) {
    useState();
  }
  useState();
}

甚至是这样的代码:

function App() {
  let loading, result;
  if (someCondition) {
    [loading] = useState();
    [result] = useState();
  } else {
    [result] = useState();
    [loading] = useState();
  }
}

条件语句会让 useState 的调用时机不够稳定,没有任何信息能够推断具体一次调用的目的。

因此 React 为了能够让这种极简风格的 API 正常工作,制订了一条规则:Hooks函数必须始终以相同的次序和数量被调用。这样,在组件的第一次渲染周期中,React 就可以记录每一个 Hooks 函数被调用的顺序,创建关联对象。在下一次渲染以及后面每一次渲染周期中,都会按照同样的顺序把关键数据返回,这才做到了每个 useState 在不同的渲染周期中都返回相同指向的数据。这就是 useState 以及其它 Hooks 可以不带参数地与当前组件绑定的秘密。

上面我们了解了用 useState 来声明状态的方法。现在我们关注一下副作用。副作用可以理解为,视图组件与组件之外系统进行交互的行为,典型的副作用有:

  1. 与浏览器 DOM 交互;
  2. 发起异步请求;
  3. 读写本次持久化缓存

上面我绑定解绑 scroll 事件,使用 fetch 的操作都属于一种副作用。以外我们只能在标识着渲染完成之后以及组件生命结束之前的 componentDidMountcomponentDidUpdatecomponentWillUnmount函数中调用。我们刚刚也总结了,同一个副作用分散在不同函数中,同一个函数又包含不同副作用,比较难以管理。

React 为我们提供了 useEffect 司职处理副作用。与类组件不同的是,它不关心是 update 还是 mount,只要是渲染完成,它就会尝试调用一次它的函数参数:

function App() {
  useEffect(() => {
    console.log('render complete');
  });
}

这样的写法你可以理解是 componentDidMountcomponentDidUpdate 的集合体。而且,在每一次新的副作用运行之前,我们还可以有机会去“清理”前一次的副作用:

function App() {
  useEffect(() => {
    console.log('render complete');
    return () => {
      console.log('clean up previous effect');
    };
  });
}

这样,每一次渲染我们都会执行一次副作用,并且有机会清理前一个副作用。如果显得过于频繁,我们还有机会控制 useEffect 的执行频次:

function App() {
  useEffect(() => {
    console.log('render complete');
    return () => {
      console.log('clean up previous effect');
    };
  }, [loading, result]);
}

看到 useEffect 的第二个参数了吗,这个数组就是 useEffect 所封装的副作用的依赖,只有依赖发生了任意变化,该副作用就会重新执行。

有两个边界情况:

  1. 不传入该参数
  2. 传入空数组

对于不传入该参数的,意味着该副作用会在每次渲染周期前后频繁执行,我们刚刚已经那样做过了。对于传入空数组的,意味着该副作用会在组件整个生命周期中只执行一次、清理一次。所以我们能轻易写出和 componentDidMountcomponentWillUnmount 等价的逻辑:

function App() {
  useEffect(() => {
    console.log('component did mount');
    return () => {
      console.log('component will unmount');
    };
  }, []);
}

这样设计有什么好处呢,请看:

function App() {
  useEffect(() => {
    document.addEventListener('scroll', onScroll, false);
    return () => {
      document.removeEventListener('scroll', onScroll, false);
    };
  }, []);
}

事件的绑定和解绑终于可以在一起了,不用再担心会遗留掉解绑、释放与析构等清理操作了,增强了内聚性。同时,绑定事件和发起异步请求终于也可以分散在两个副作用当中了,对于这种不相干逻辑的解耦合操作,我们称之为关注点分离,软件设计原则中一个重要的概念。

React 内置的 Hooks 函数还有 useMemo/useCallback/useRef/useReducer 等等,都是整个 Hooks 系统中不可或缺的组成部分。

令人振奋的是 Hooks 函数不仅仅可以在组件中使用,我们还可以把一系列的 Hooks 组合拿出来,封装成另一个 Hook 函数,比如:

function useScroll() {
  const [top, setTop] = useState(document.documentElement.scrollTop);
  
  function onScroll() {
    setTop(document.documentElement.scrollTop)
  }

  useEffect(() => {
    document.addEventListener('scroll', onScroll, false);
    return () => {
      document.removeEventListener('scroll', onScroll, false);
    };
  }, []);
  
  return top;
}

function App() {
  const top = useScroll();
}

大家应该想到了,这不就是把组件的一部分逻辑提取出来自立门户了吗?没错,如果自定义的 Hook 函数也返回 JSX 的话,那么很可能都不太好分辨这究竟是一个自定义 Hook 函数还是一个组件。

在 Hooks 的环境下,组件的概念已经模糊了,它可以被任意拆解成 Hook 与组件的组合。以往我们一般基于视图结构来拆解组件,现在我们有能力基于状态逻辑来拆解,毫无疑问地让分享状态逻辑不那么困难了。相比于在面向对象系统中的继承式派生,Hooks 是基于函数编程的组合式派生,简单、灵活也不失优雅。

当然,由于这个嵌套关系的存在,违背“Hooks 函数必须始终以相同的次序和数量被调用”这一原则变得很容易发生,因此 React 又规定了另外一条原则:Hooks 的调用都必须出现在组件或者自定义 Hook 的最上层,有利于维护和故障诊断。

所以你不应该把调用 Hooks 函数的语句放在任何 if 条件语句、for/while 循环语句等任何非顶层语句块中,回调函数则更不可以。为了杜绝此类行为,React 团队还提供了一个 eslint 的插件:eslint-plugin-react-hooks,可以帮助我们找到潜在的错误。

Hooks 刚刚出现不久,React 保证了大多数业务的平滑过度,不过依然有某些能力是目前的 Hooks 所解决不了的。

比如 getSnapshotBeforeUpdategetDerivedStateFromError,现在还没有实现,所以我们暂时没有办法通过创建 ErrorBoundary 来捕获运行时渲染异常。所以我们也能得出一个结论,截至目前为止,我们还没有办法完全抛弃类组件

在 Web 应用的重心不断前移的大趋势下,前端所承载业务的复杂度在节节攀升,以至于如果不使用 React、Vue、Angular 这类框架的话,则开发过程会举步维艰。在这样的背景下,开发效率、维护效率则不断被提起,特别是小型创业团队,有生力量十分有限,如何以最少的人力来支撑最多的业务是一个经久不衰的话题。

即便是第三方框架大行其道的今天,开发者也始终在被不断攀升的复杂度所引起的臃肿架构、数据欠一致性等量变引起的质变问题所困扰。一种更松散的架构是工程师所梦寐以求的。React Hooks 是前端领域追求极致效率与体验的产物,没有拘泥于传统的 OOP 模式,反其道而行之,力求以最简洁的语法带来最高效的开发体验。

鉴于其绝对领先的架构和开发体验,未来的 React 开发已经不再留给类组件以多少余地了。我们有理由相信,在 React 团队的持续高效输出下,2020年之前一定会让类组件完全失去生存空间,未来必然是函数组件的天下。到时候,个人在 Hooks 领域内所积累的经验和知识,则成为了其弥足珍贵的核心竞争力

不但 React 自己在进化,Hooks 同时也带来了生态内相关解决方案的升级,比如 react-redux,即将发布带有Hook 函数的版本,未来在 React 项目中使用 redux 则完全是另一幅风景。设想有越来越多的解决方案去适配 Hooks,带来的将是整个体系在研发方式上的重大革新。

那就就让我们憧憬 Hooks 带来的全新世界,体验尖端思想所带来的现实魅力!

40人推荐
随时随地看视频
慕课网APP

热门评论

好棒的解读! “前端比起整个软件工程乃至计算机科学体系来说,是个相对新生草莽的领域,近年来前端生态的发展其实都是在向其他领域吸收和学习,不论是开发理念、工程实践还是平台本身(规范、浏览器)。” 当别人开始实践新技术Hooks TypeScript Webpack4 Bable7 Flutter SSR PWA…… 为了提高职场核心竞争力、开发效率、可维护性……大胆地去尝试吧!

这里的render中的<Child>标签是什么?不应该是<Component>吗


好棒的解读! “前端比起整个软件工程乃至计算机科学体系来说,是个相对新生草莽的领域,近年来前端生态的发展其实都是在向其他领域吸收和学习,不论是开发理念、工程实践还是平台本身(规范、浏览器)。” 当别人开始实践新技术Hooks TypeScript Webpack4 Bable7 Flutter SSR PWA…… 为了提高职场核心竞争力、开发效率、可维护性……大胆地去尝试吧!

查看全部评论