cisen / blog

Time waits for no one.
134 stars 20 forks source link

React@16 新特性整理 #173

Open cisen opened 5 years ago

cisen commented 5 years ago

生命周期函数的更新

随着 React 16.0 发布, React 采用了新的内核架构 Fiber,在新的架构中它将更新分为两个阶段:Render Parse 和 Commit Parse, 也由此引入了 getDerivedStateFromProps 、 getSnapshotBeforeUpdate 及 componentDidCatch 等三个生命周期函数。同时,也将 componentWillMount、componentWillReceiveProps 和 componentWillUpdate 标记为不安全的方法。

new lifecycle

新增

static getDerivedStateFromProps(nextProps, prevState)
getSnapshotBeforeUpdate(prevProps, prevState)
componentDidCatch(error, info)

标记为不安全

componentWillMount(nextProps, nextState)
componentWillReceiveProps(nextProps)
componentWillUpdate(nextProps, nextState)
static getDerivedStateFromProps(nextProps, prevState)

根据 getDerivedStateFromProps(nextProps, prevState) 的函数签名可知: 其作用是根据传递的 props 来更新 state。它的一大特点是 无副作用 : 由于处在 Render Phase 阶段,所以在每次的更新都要触发, 故在设计 API 时采用了静态方法,其好处是单纯 —— 无法访问实例、无法通过 ref 访问到 DOM 对象等,保证了单纯且高效。值得注意的是,其仍可以通过 props 的操作来产生副作用,这时应该将操作 props 的方法移到 componentDidUpdate 中,减少触发次数。

例:

state = { isLogin: false }

static getDerivedStateFromProps(nextProps, prevState) {
  if(nextProps.isLogin !== prevState.isLogin){
    return {
      isLogin: nextProps.isLogin
    }
  }
  return null
}

componentDidUpdate(prevProps, prevState){
  if(!prevState.isLogin && prevProps.isLogin) this.handleClose()
}

但在使用时要非常小心,因为它不像 componentWillReceiveProps 一样,只在父组件重新渲染时才触发,本身调用 setState 也会触发。官方提供了 3 条 checklist, 这里搬运一下:

如果改变 props 的同时,有副作用的产生(如异步请求数据,动画效果),这时应该使用 componentDidUpdate 如果想要根据 props 计算属性,应该考虑将结果 memoization 化,参见 memoization 如果想要根据 props 变化来重置某些状态,应该考虑使用受控组件 配合 componentDidUpdate 周期函数,getDerivedStateFromProps 是为了替代 componentWillReceiveProps 而出现的。它将原本 componentWillReceiveProps 功能进行划分 —— 更新 state 和 操作/调用 props,很大程度避免了职责不清而导致过多的渲染, 从而影响应该性能。

getSnapshotBeforeUpdate(prevProps, prevState)

根据 getSnapshotBeforeUpdate(prevProps, prevState) 的函数签名可知,其在组件更新之前获取一个 snapshot —— 可以将计算得的值或从 DOM 得到的信息传递到 componentDidUpdate(prevProps, prevState, snapshot) 周期函数的第三个参数,常常用于 scroll 位置的定位。摘自官方的示例:

class ScrollingList extends React.Component {
  constructor(props) {
    super(props)
    // 取得dom 节点
    this.listRef = React.createRef()
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    // 根据新添加的元素来计算得到所需要滚动的位置
    if (prevProps.list.length < this.props.list.length) {
      const list = this.listRef.current
      return list.scrollHeight - list.scrollTop
    }
    return null
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // 根据 snapshot 计算得到偏移量,得到最终滚动位置
    if (snapshot !== null) {
      const list = this.listRef.current
      list.scrollTop = list.scrollHeight - snapshot
    }
  }

  render() {
    return <div ref={this.listRef}>{/* ...contents... */}</div>
  }
}
componentDidCatch(error, info)

在 16.0 以前,错误捕获使用 unstable_handleError 或者采用第三方库如 react-error-overlay 来捕获,前者捕获的信息十分有限,后者为非官方支持。而在 16.0 中,增加了 componentDidCatch 周期函数来让开发者可以自主处理错误信息,诸如展示,上报错误等,用户可以创建自己的Error Boundary 来捕获错误。例:

···

 componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

···

此外,用户还可以采用第三方错误追踪服务,如 Sentry、Bugsnag 等,保证了错误处理效率的同时也极大降级了中小型项目错误追踪的成本。

图片bugsnag

标记为不安全 componentWillMount、componentWillReceiveProps、componentWillUpdate componentWillMount componentWillMount 可被开发者用于获取首屏数据或事务订阅。

开发者为了快速得到数据,将首屏请求放在 componentWillMount中。实际上在执行 componentWillMount时第一次渲染已开始。把首屏请求放在componentWillMount 的与否都不能解决首屏渲染无异步数据的问题。而官方的建议是将首屏放在 constructor 或 componentDidMount中。

此外事件订阅也被常在 componentWillMount 用到,并在 componentWillUnmount 中取消掉相应的事件订阅。但事实上 React 并不能够保证在 componentWillMount 被调用后,同一组件的 componentWillUnmount 也一定会被调用。另一方面,在未来 React 开启异步渲染模式后,在 · 被调用之后,组件的渲染也很有可能会被其他的事务所打断,导致 componentWillUnmount 不会被调用。而 componentDidMount 就不存在这个问题,在 componentDidMount 被调用后,componentWillUnmount 一定会随后被调用到,并根据具体代码清除掉组件中存在的事件订阅。

对此的升级方案是把 componentWillMount 改为 componentDidMount 即可。

componentWillReceiveProps、componentWillUpdate

componentWillReceiveProps 被标记为不安全的原因见前文所述,其主要原因是操作 props 引起的 re-render。与之类似的 componentWillUpdate 被标记为不安全也是同样的原因。除此之外,对 DOM 的更新操作也可能导致重新渲染。

对于 componentWillReceiveProps 的升级方案是使用 getDerivedStateFromProps 和 componentDidUpdate 来代替。 对于 componentWillUpdate 的升级方案是使用 componentDidUpdate 代替。如涉及大量的计算,可在 getSnapshotBeforeUpdate 完成计算,再在 componentDidUpdate 一次完成更新。

通过框架级别的 API 来约束甚至限制开发者写出更易维护的 Javascript 代码,最大限度的避免了反模式的开发方式。

render 方法优化

为了符合 React 的 component tree 和 diff 结构设计,在组件的 render() 方法中顶层必须包裹为单节点,因此实际组件设计和使用中总是需要注意嵌套后的层级变深,这是 React 的一个经常被人诟病的问题。比如以下的内容结构就必须再嵌套一个 div 使其变为单节点进行返回:

render() {
  return (
    <div>
      注:
      <p>产品说明一</p>
      <p>产品说明二</p>
    </div>
  );
}

现在在更新 v16 版本后,这个问题有了新的改进,render 方法可以支持返回数组了:

render() {
  return [
    "注:",
    <p key="t-1">产品说明一</h2>,
    <p key="t-2">产品说明二</h2>,
  ];
}

这样确实少了一层,但大家又继续发现代码还是不够简洁。首先 TEXT 节点需要用引号包起来,其次由于是数组,每条内容当然还需要添加逗号分隔,另外 element 上还需要手动加 key 来辅助 diff。给人感觉就是不像在写 JSX 了。

于是 React v16.2 趁热打铁,提供了更直接的方法,就是 Fragment:

render() {
  return (
    <React.Fragment>
      注:        
      <p>产品说明一</p>
      <p>产品说明二</p>
    </React.Fragment>
  );
}
可以看到是一个正常单节点写法,直接包裹里面的内容。但是 Fragment 本身并不会产生真实的 DOM 节点,因此也不会导致层级嵌套增加。

另外 Fragment 还提供了新的 JSX 简写方式 <></>:
```js
render() {
  return (
    <>
      注:
      <p>产品说明一</p>
      <p>产品说明二</p>
    </>
  );}

看上去是否舒服多了。不过注意如果需要给 Fragment 添加 key prop,是不支持使用简写的(这也是 Fragment 唯一会遇到需要添加props的情况):

<dl>
  {props.items.map(item => (
    // 要传key用不了 <></>
    <Fragment key={item.id}>
      <dt>{item.term}</dt>
      <dd>{item.description}</dd>
    </Fragment>
  ))}
</dl>

错误边界 (Error Boundaries)

错误边界是指以在组件上定义 componentDidCatch 方法的方式来创建一个有错误捕捉功能的组件,在其内嵌套的组件在生命过程中发生的错误都会被其捕捉到,而不会上升到外部导致整个页面和组件树异常 crash。

例如下面的例子就是通过一个 ErrorBoundary 组件对其内的内容进行保护和错误捕捉,并在发生错误时进行兜底的UI展示:

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }
  componentDidCatch(error, 
   {componentStack}
  ) {
    this.setState({
      error,
      componentStack,
    });
  }
  render() {
    if (this.state.error) {
      return (
        <>
          <h1>报错了.</h1>
          <ErrorPanel {...this.state} />
        </>
      );
    }
    return this.props.children;
  }
}

export default function App(){
  return (
    <ErrorBoundary>
      <Content />
    </ErrorBoundary>
  );
}

需要注意的是错误边界只能捕捉生命周期中的错误 (willMount / render 等方法内)。无法捕捉异步的、事件回调中的错误,要捕捉和覆盖所有场景依然需要配合 window.onerror、Promise.catch、 try/catch 等方式。

React.createPortal()

这个 API 是用来将部分内容分离式地 render 到指定的 DOM 节点上。不同于使用 ReactDom.render 新创建一个 DOM tree 的方式,对于要通过 createPortal() “分离”出去的内容,其间的数据传递,生命周期,甚至事件冒泡,依然存在于原本的抽象组件树结构当中。

class Creater extends Component {
  render(){
    return (
      <div onClick={() => 
        alert("clicked!")
      }>
        <Portal>
          <img src={myImg} />
        </Portal>
      </div>
    ); 
  }
}

class Portal extends Component {
  render(){
    const node = getDOMNode();
    return createPortal(
      this.props.children,
      node 
    ); 
  }
}

例如以上代码,<Creater> 通过 <Portal> 把里面的 <img > 内容渲染到了一个独立的节点上。在实际的 DOM 结构中,img 已经脱离了 Creater 本身的 DOM 树存在于另一个独立节点。但当点击 img 时,仍然可以神奇的触发到 Creater 内的 div 上的 onclick 事件。这里实际依赖于 React 代理和重写了整套事件系统,让整个抽象组件树的逻辑得以保持同步。

Context API

以前的版本中 Context API 是作为未公开的实验性功能存在的,随着越来越多的声音要求对其进行完善,在 v16.3 版本,React 团队重新设计并发布了新的官方 Context API。

使用 Context API 可以更方便的在组件中传递和共享某些 "全局" 数据,这是为了解决以往组件间共享公共数据需要通过多余的 props 进行层层传递的问题 (props drilling)。比如以下代码:

const HeadTitle = (props) => {
  return (
    <Text>
    {props.lang.title}
    </Text>;
  );
};

// 中间组件
const Head = (props) => {
  return (
    <div>
      <HeadTitle lang={props.lang} />
    </div>
  );
};

class App extends React.Component {
  render() {
    return (
      <Head lang={this.props.lang} />;
    );
  }
}

export default App = connect((state) => {
  return {
    lang:state.lang
  }
})(App);

我们为了使用一个语言包,把语言配置存储到一个 store 里,通过 Redux connect 到顶层组件,然而仅仅是最底端的子组件才需要用到。我们也不可能为每个组件都单独加上 connect,这会造成数据驱动更新的重复和不可维护。因此中间组件需要一层层不断传递下去,就是所谓的 props drilling。

对于这种全局、不常修改的数据共享,就比较适合用 Context API 来实现:

首先第一步,类似 store,我们可以先创建一个 Context,并加入默认值:

const LangContext = React.createContext({
  title:"默认标题"
});

然后在顶层通过 Provider 向组件树提供 Context 的访问。这里可以通过传入 value 修改 Context 中的数据,当value变化的时候,涉及的 Consumer 内整个内容将重新 render:

class App extends React.Component {
  render() {
    return (
      <LangContext.Provider
        value={this.state.lang}
      >
        <Head />
      </LangContext.Provider>
    );
  }
}

在需要使用数据的地方,直接用 Context.Consumer 包裹,里面可以传入一个 render 函数,执行时从中取得 Context 的数据。

const HeadTitle = (props) => {
  return (
    <LangContext.Consumer>
      {lang => 
        <Text>{lang.title}</Text>
      }
    </LangContext.Consumer>
  );
};

之后的中间组件也不再需要层层传递了,少了很多 props,减少了中间漏传导致出错,代码也更加清爽:

// 中间组件
const Head = () => {
  return (
    <div>
      <HeadTitle />
    </div>
  );
};

那么看了上面的例子,我们是否可以直接使用 Context API 来代替掉所有的数据传递,包括去掉 redux 这些数据同步 library 了?其实并不合适。前面也有提到,Context API 应该用于需要全局共享数据的场景,并且数据最好是不用频繁更改的。因为作为上层存在的 Context,在数据变化时,容易导致所有涉及的 Consumer 重新 render。

比如下面这个例子:

render() {
  return (
    <Provider value={{
      title:"my title"
    }} >
      <Content />
    </Provider>
  );
}

实际每次 render 的时候,这里的 value 都是传入一个新的对象。这将很容易导致所有的 Consumer 都重新执行 render 影响性能。

因此不建议滥用 Context,对于某些非全局的业务数据,也不建议作为全局 Context 放到顶层中共享,以免导致过多的 Context 嵌套和频繁重新渲染。

Ref API

除了 Context API 外,v16.3 还推出了两个新的 Ref API,用来在组件中更方便的管理和使用 ref。

在此之前先看一下我们之前使用 ref 的两种方法。

// string命名获取
componentDidMount(){
  console.log(this.refs.input);
}
render() {
  return (
    <input 
        ref="input"
    />
  );
}
// callback 获取
render() {
  return (
    <input 
        ref={el => {this.input = el;}}
    />
  );
}

前一种 string 的方式比较局限,不方便于多组件间的传递或动态获取。后一种 callback 方法是之前比较推荐的方法。但是写起来略显麻烦,而且 update 过程中有发生清除可能会有多次调用 (callback 收到 null)。

为了提升易用性,新版本推出了 CreateRef API 来创建一个 ref object, 传递到 component 的 ref 上之后可以直接获得引用:

constructor(props) {
  super(props);
  this.input = React.createRef();
}
componentDidMount() {
  console.log(this.input);
}
render() {
  return <input ref={this.input} />;
}

另外还提供了 ForwardRef API 来辅助简化嵌套组件、component 至 element 间的 ref 传递,避免出现 this.ref.ref.ref 的问题。

例如我们有一个包装过的 Button 组件,想获取里面真正的 button DOM element,本来需要这样做:

class MyButton extends Component {
  constructor(props){
    super(props);
    this.buttonRef = React.createRef();
  }
  render(){
    return (
      <button ref={this.buttonRef}>
        {props.children}
      </button>
    );
  }
}
class App extends Component {
  constructor(props){
    super(props);
    this.myRef = React.createRef();
  }
  componentDidComponent{
    // 通过ref一层层访问
    console.log(this.myRef.buttonRef);
  }
  render(){
    return (
      <MyButton ref={this.myRef}>
        Press here
      </MyButton>
    );
  }
}

这种场景使用 forwardRef API 的方式做一个“穿透”,就能简便许多:

import { createRef, forwardRef } from "react";

const MyButton = forwardRef((props, ref) => (
  <button ref={ref}>
    {props.children}
  </button>
));

class App extends Component {
  constructor(props){
    super(props);
    this.realButton = createRef();
  }
  componentDidComponent{
    //直接拿到 inner element ref
    console.log(this.realButton);
  }
  render(){
    return (
    <MyButton ref={this.realButton}>
      Press here
    </MyButton>
    );
  }
}

React Strict Mode

React StrictMode 可以在开发阶段发现应用存在的潜在问题,提醒开发者解决相关问题,提供应用的健壮性。其主要能检测到 4 个问题:

使用起来也很简单,只要在需要被检测的组件上包裹一层 React StrictMode ,示例代码 React-StictMode:

class App extends React.Component {
  render() {
    return (
      <div>
        <React.StrictMode>
          <ComponentA />
        </React.StrictMode>
      </div>
    )
  }
}

若出现错误,则在控制台输出具体错误信息:

React Strict Mode

suspense

Suspense要解决的两个问题:

刚开始的时候, React 觉得自己只是管视图的, 代码打包的事不归我管, 怎么拿数据也不归我管。 代码都打到一起, 比如十几M, 下载就要半天,体验显然不会好到哪里去。

可是后来呢,这两个事情越来越重要, React 又觉得, 嗯,还是要掺和一下,是时候站出来展现真正的技术了。

Suspense 在v16.6的时候 已经解决了代码分片的问题,异步获取数据还没有正式发布。

先看一个简单的例子:

import React from "react";
import moment from "moment";

const Clock = () => <h1>{moment().format("MMMM Do YYYY, h:mm:ss a")}</h1>;

export default Clock;

假设我们有一个组件, 是看当前时间的, 它用了一个很大的第三方插件, 而我想只在用的时候再加载资源,不打在总包里。

再看一段代码:

// Usage of Clock
const Clock = React.lazy(() => {
  console.log("start importing Clock");
  return import("./Clock");
});

这里我们使用了React.lazy, 这样就能实现代码的懒加载。 React.lazy 的参数是一个function, 返回的是一个promise. 这里返回的是一个import 函数, webpack build 的时候, 看到这个东西, 就知道这是个分界点。 import 里面的东西可以打包到另外一个包里。

真正要用的话, 代码大概是这个样子的:

<Suspense fallback={<Loading />}>
  { showClock ? <Clock/> : null}
</Suspense>

showClock 为 true, 就尝试render clock, 这时候, 就触发另一个事件: 去加载clock.js 和它里面的 lib momment。

看到这你可能觉得奇怪, 怎么还需要用个 包起来, 有啥用, 不包行不行。

哎嗨, 不包还真是不行。 为什么呢?

前面我们说到, 目前react 的渲染模式还是同步的, 一口气走到黑, 那我现在画到clock 这里, 但是这clock 在另外一个文件里, 服务器就需要去下载, 什么时候能下载完呢, 不知道。 假设你要花十分钟去下载, 那这十分钟你让react 去干啥, 总不能一直等你吧。 Suspens 就是来解决这个问题的, 你要画clock, 现在没有,那就会抛一个异常出来,我们之前说 componentDidCatch 和 getDerivedStateFromProps, 这两个函数就是来抓子组件 或者 子子组件抛出的异常的。

子组件有异常的时候就会往上抛,直到某个组件的 getDerivedStateFromProps 抓住这个异常,抓住之后干嘛呢, 还能干嘛呀, 忍着。 下载资源的时候会抛出一个promise, 会有地方(这里是suspense)捕捉这个promise, suspense 实现了getDerivedStateFromProps, getDerivedStateFromProps 捕获到异常的时候, 一看, 哎, 小老弟,你来啦,还是个promise, 然后就等这个promise resole, resolve 完成之后呢,它会尝试重新画一下子组件。这时候资源已经到本地了, 也就能画成功了。

用伪代码 大致实现一下:

getDerivedStateFromError(error) {
   if (isPromise(error)) {
      error.then(reRender);
   }
}

以上大概就是Suspense 的原理, 其实也不是很复杂,就是利用了 componentDidCatch 和 getDerivedStateFromError, 其实刚开始在v16的时候, 是要用componentDidCatch 的, 但它毕竟是commit phase 里的东西, 还是分出来吧, 所以又加了个getDerivedStateFromError来实现 Suspense 的功能。

这里需要注意的是 reRender 会渲染suspense 下面的所有子组件。

异步渲染什么时候开启呢, 根据介绍说是在19年的第二个季度随着一个小版本的升级开启, 让我们提前做好准备。

做些什么准备呢?

render 函数之前的代码都检查一边, 避免一些有副作用的操作 到这, 我们说完了Suspense 的一半功能, 还有另一半: 异步获取数据。

目前这一部分功能还没正式发布。 那我们获取数据还是只能在commit phase 做, 也就是在componentDidMount 里 或者 didUpdate 里做。

就目前来说, 如果一个组件要自己获取数据, 就必须实现为一个类组件, 而且会画两次, 第一次没有数据, 是空的, 你可以画个loading, didMount 之后发请求, 数据回来之后, 把数据setState 到组件里, 这时候有数据了, 再画一次,就画出来了。

虽然是一个很简答的功能, 我就想请求个数据, 还要写一堆东西, 很麻烦, 但在目前的正式版里, 不得不这么做。

但以后这种情况会得到改善, 看一段示例:

import {unstable_createResource as createResource} from 'react-cache';

const resource = createResource(fetchDataApi);

const Foo = () => {
  const result = resource.read();
  return (
    <div>{result}</div>
  );

// ...

<Suspense>
   <Foo />
</Suskpense>};

代码里我们看不到任何譬如 async await 之类的操作, 看起来完全是同步的操作, 这是什么原理呢。

上面的例子里, 有个 resource.read(), 这里就会调api, 返回一个promise, 上面会有suspense 抓住, 等resolve 的时候,再画一下, 就达到目的了。

到这,细心的同学可能就发现了一个问题, resource.read(); 明显是一个有副作用的操作, 而且 render 函数又属于render phase, 之前又说, 不建议在 render phase 里做有副作用的操作, 这么矛盾, 不是自己打脸了吗。

这里也能看出来React 团队现在还没完全想好, 目前放出来测试api 也是以unstable_开头的, 不用用意还是跟明显的: 让大家不要写class的组件,Suspense 能很好的支持函数式组件。

hooks

React v16.7.0-alpha 中第一次引入了 Hooks 的概念, 为什么要引入这个东西呢?

有两个原因:

但是React 官方又说, Hooks的目的并不是消灭类组件。此处应手动滑稽。 回归正题, 我们继续看Hooks, 首先看一下官方的API:

clipboard.png

乍一看还是挺多的, 其实有很多的Hook 还处在实验阶段,很可能有一部分要被砍掉, 目前大家只需要熟悉的, 三个就够了:

举个例子来看下, 一个简单的counter :

// 有状态类组件
class Counter extends React.Component {
   state = {
      count: 0
   }

   increment = () => {
       this.setState({count: this.state.count + 1});
   }

   minus = () => {
       this.setState({count: this.state.count - 1});
   }

   render() {
       return (
           <div>
               <h1>{this.state.count}</h1>
               <button onClick={this.increment}>+</button>
               <button onClick={this.minus}>-</button>
           </div>
       );
   }
}
// 使用useState Hook
const Counter = () => {
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);

  return (
    <div>
        <h1>{count}</h1>
        <button onClick={increment}>+</button>
    </div>
  );
};

这里的Counter 不是一个类了, 而是一个函数。

进去就调用了useState, 传入 0,对state 进行初始化,此时count 就是0, 返回一个数组, 第一个元素就是 state 的值,第二个元素是更新 state 的函数。

// 下面代码等同于: const [count, setCount] = useState(0);
  const result = useState(0);
  const count = result[0];
  const setCount = result[1];

利用 count 可以读取到这个 state,利用 setCount 可以更新这个 state,而且我们完全可以控制这两个变量的命名,只要高兴,你完全可以这么写:

const [theCount, updateCount] = useState(0);

因为 useState 在 Counter 这个函数体中,每次 Counter 被渲染的时候,这个 useState 调用都会被执行,useState 自己肯定不是一个纯函数,因为它要区分第一次调用(组件被 mount 时)和后续调用(重复渲染时),只有第一次才用得上参数的初始值,而后续的调用就返回“记住”的 state 值。

读者看到这里,心里可能会有这样的疑问:如果组件中多次使用 useState 怎么办?React 如何“记住”哪个状态对应哪个变量?

React 是完全根据 useState 的调用顺序来“记住”状态归属的,假设组件代码如下:

const Counter = () => {
  const [count, setCount] = useState(0);
  const [foo, updateFoo] = useState('foo');

  // ...
}

每一次 Counter 被渲染,都是第一次 useState 调用获得 count 和 setCount,第二次 useState 调用获得 foo 和 updateFoo(这里我故意让命名不用 set 前缀,可见函数名可以随意)。

React 是渲染过程中的“上帝”,每一次渲染 Counter 都要由 React 发起,所以它有机会准备好一个内存记录,当开始执行的时候,每一次 useState 调用对应内存记录上一个位置,而且是按照顺序来记录的。React 不知道你把 useState 等 Hooks API 返回的结果赋值给什么变量,但是它也不需要知道,它只需要按照 useState 调用顺序记录就好了。

你可以理解为会有一个槽去记录状态。

正因为这个原因,Hooks,千万不要在 if 语句或者 for 循环语句中使用!

像下面的代码,肯定会出乱子的:

const Counter = () => {
    const [count, setCount] = useState(0);
    if (count % 2 === 0) {
        const [foo, updateFoo] = useState('foo');
    }
    const [bar, updateBar] = useState('bar');
 // ...
}

因为条件判断,让每次渲染中 useState 的调用次序不一致了,于是 React 就错乱了。

useEffect

除了 useState,React 还提供 useEffect,用于支持组件中增加副作用的支持。 在 React 组件生命周期中如果要做有副作用的操作,代码放在哪里? 当然是放在 componentDidMount 或者 componentDidUpdate 里,但是这意味着组件必须是一个 class。 在 Counter 组件,如果我们想要在用户点击“+”或者“-”按钮之后把计数值体现在网页标题上,这就是一个修改 DOM 的副作用操作,所以必须把 Counter 写成 class,而且添加下面的代码:

componentDidMount() {
  document.title = `Count: ${this.state.count}`;
}

componentDidUpdate() {
  document.title = `Count: ${this.state.count}`;
}

而有了 useEffect,我们就不用写一个 class 了,对应代码如下:

import { useState, useEffect } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Count: ${this.state.count}`;
  });

  return (
    <div>
       <div>{count}</div>
       <button onClick={() => setCount(count + 1)}>+</button>
       <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
};

useEffect 的参数是一个函数,组件每次渲染之后,都会调用这个函数参数,这样就达到了 componentDidMount 和 componentDidUpdate 一样的效果。

虽然本质上,依然是 componentDidMount 和 componentDidUpdate 两个生命周期被调用,但是现在我们关心的不是 mount 或者 update 过程,而是“after render”事件,useEffect 就是告诉组件在“渲染完”之后做点什么事。

读者可能会问,现在把 componentDidMount 和 componentDidUpdate 混在了一起,那假如某个场景下我只在 mount 时做事但 update 不做事,用 useEffect 不就不行了吗?

其实,用一点小技巧就可以解决。useEffect 还支持第二个可选参数,只有同一 useEffect 的两次调用第二个参数不同时,第一个函数参数才会被调用. 所以,如果想模拟 componentDidMount,只需要这样写:

  useEffect(() => {
    // 这里只有mount时才被调用,相当于componentDidMount
  }, [123]);

在上面的代码中,useEffect 的第二个参数是 [123],其实也可以是任何一个常数,因为它永远不变,所以 useEffect 只在 mount 时调用第一个函数参数一次,达到了 componentDidMount 一样的效果。

useContext

在前面介绍“提供者模式”章节我们介绍过 React 新的 Context API,这个 API 不是完美的,在多个 Context 嵌套的时候尤其麻烦。

比如,一段 JSX 如果既依赖于 ThemeContext 又依赖于 LanguageContext,那么按照 React Context API 应该这么写:

<ThemeContext.Consumer>
    {
        theme => (
            <LanguageContext.Cosumer>
                language => {
                    //可以使用theme和lanugage了
                }
            </LanguageContext.Cosumer>
        )
    }
</ThemeContext.Consumer>

因为 Context API 要用 render props,所以用两个 Context 就要用两次 render props,也就用了两个函数嵌套,这样的缩格看起来也的确过分了一点点。

使用 Hooks 的 useContext,上面的代码可以缩略为下面这样:

const theme = useContext(ThemeContext);
const language = useContext(LanguageContext);
// 这里就可以用theme和language了

这个useContext把一个需要很费劲才能理解的 Context API 使用大大简化,不需要理解render props,直接一个函数调用就搞定。

但是,useContext也并不是完美的,它会造成意想不到的重新渲染,我们看一个完整的使用useContext的组件。

const ThemedPage = () => {
    const theme = useContext(ThemeContext);

    return (
       <div>
            <Header color={theme.color} />
            <Content color={theme.color}/>
            <Footer color={theme.color}/>
       </div>
    );
};

因为这个组件ThemedPage使用了useContext,它很自然成为了Context的一个消费者,所以,只要Context的值发生了变化,ThemedPage就会被重新渲染,这很自然,因为不重新渲染也就没办法重新获得theme值,但现在有一个大问题,对于ThemedPage来说,实际上只依赖于theme中的color属性,如果只是theme中的size发生了变化但是color属性没有变化,ThemedPage依然会被重新渲染,当然,我们通过给Header、Content和Footer这些组件添加shouldComponentUpdate实现可以减少没有必要的重新渲染,但是上一层的ThemedPage中的JSX重新渲染是躲不过去了。

说到底,useContext 需要一种表达方式告诉React:“我没有改变,重用上次内容好了。”

希望Hooks正式发布的时候能够弥补这一缺陷。

Hooks 可以引用其他 Hooks

我们可以这么做:

import { useState, useEffect } from "react";

// 底层 Hooks, 返回布尔值:是否在线
function useFriendStatusBoolean(friendID) {
  const [isOnline, setIsOnline] = useState(null);

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

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

  return isOnline;
}

// 上层 Hooks,根据在线状态返回字符串:Loading... or Online or Offline
function useFriendStatusString(props) {
  const isOnline = useFriendStatusBoolean(props.friend.id);

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

// 使用了底层 Hooks 的 UI
function FriendListItem(props) {
  const isOnline = useFriendStatusBoolean(props.friend.id);

  return (
    <li style={{ color: isOnline ? "green" : "black" }}>{props.friend.name}</li>
  );
}

// 使用了上层 Hooks 的 UI
function FriendListStatus(props) {
  const statu = useFriendStatusString(props.friend.id);

  return <li>{statu}</li>;
}

这个例子中,有两个 Hooks:useFriendStatusBooleanuseFriendStatusString, useFriendStatusString 是利用 useFriendStatusBoolean 生成的新 Hook,这两个 Hook 可以给不同的 UI:FriendListItemFriendListStatus 使用,而因为两个 Hooks 数据是联动的,因此两个 UI 的状态也是联动的。

顺带一提,这个例子也可以用来理解 对 React Hooks 的一些思考 一文的那句话:“有状态的组件没有渲染,有渲染的组件没有状态”:

useFriendStatusBooleanuseFriendStatusString 是有状态的组件(使用 useState),没有渲染(返回非 UI 的值),这样就可以作为 Custom Hooks 被任何 UI 组件调用。 FriendListItemFriendListStatus是有渲染的组件(返回了 JSX),没有状态(没有使用 useState),这就是一个纯函数 UI 组件,

利用 useState 创建 Redux

Redux 的精髓就是 Reducer,而利用 React Hooks 可以轻松创建一个 Redux 机制:

// 这就是 Redux
function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

这个自定义 Hook 的 value 部分当作 redux 的 state,setValue 部分当作 redux 的 dispatch,合起来就是一个 redux。而 react-redux 的 connect 部分做的事情与 Hook 调用一样:

// 一个 Action
function useTodos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  function handleAddClick(text) {
    dispatch({ type: "add", text });
  }

  return [todos, { handleAddClick }];
}

// 绑定 Todos 的 UI
function TodosUI() {
  const [todos, actions] = useTodos();
  return (
    <>
      {todos.map((todo, index) => (
        <div>{todo.text}</div>
      ))}
      <button onClick={actions.handleAddClick}>Add Todo</button>
    </>
  );
}

useReducer 已经作为一个内置 Hooks 了,在这里可以查阅所有 内置 Hooks。

不过这里需要注意的是,每次 useReducer 或者自己的 Custom Hooks 都不会持久化数据,所以比如我们创建两个 App,App1 与 App2:

function App1() {
  const [todos, actions] = useTodos();

  return <span>todo count: {todos.length}</span>;
}

function App2() {
  const [todos, actions] = useTodos();

  return <span>todo count: {todos.length}</span>;
}

function All() {
  return (
    <>
      <App1 />
      <App2 />
    </>
  );
}

这两个实例同时渲染时,并不是共享一个 todos 列表,而是分别存在两个独立 todos 列表。也就是 React Hooks 只提供状态处理方法,不会持久化状态。

如果要真正实现一个 Redux 功能,也就是全局维持一个状态,任何组件 useReducer 都会访问到同一份数据,可以和 useContext 一起使用。

大体思路是利用 useContext 共享一份数据,作为 Custom Hooks 的数据源。具体实现可以参考 redux-react-hook

Hooks 带来的代码模式改变

上面我们介绍了 useState、useEffect 和 useContext 三个最基本的 Hooks,可以感受到,Hooks 将大大简化使用 React 的代码。

首先我们可能不再需要 class了,虽然 React 官方表示 class 类型的组件将继续支持,但是,业界已经普遍表示会迁移到 Hooks 写法上,也就是放弃 class,只用函数形式来编写组件。

对于 useContext,它并没有为消除 class 做贡献,却为消除 render props 模式做了贡献。很长一段时间,高阶组件和 render props 是组件之间共享逻辑的两个武器,但如同我前面章节介绍的那样,这两个武器都不是十全十美的,现在 Hooks 的出现,也预示着高阶组件和 render props 可能要被逐步取代。

但读者朋友,不要觉得之前学习高阶组件和 render props 是浪费时间,相反,你只有明白 React 的使用历史,才能更好地理解 Hooks 的意义。

可以预测,在 Hooks 兴起之后,共享代码之间逻辑会用函数形式,而且这些函数会以 use- 前缀为约定,重用这些逻辑的方式,就是在函数形式组件中调用这些 useXXX 函数。

例如,我们可以写这样一个共享 Hook useMountLog,用于在 mount 时记录一个日志,代码如下:

const useMountLog = (name) => {
    useEffect(() => {
        console.log(`${name} mounted`);    
    }, [123]);
}

任何一个函数形式组件都可以直接调用这个 useMountLog 获得这个功能,如下:

const Counter = () => {
    useMountLog('Counter');

    ...
}

对了,所有的 Hooks API 都只能在函数类型组件中调用,class 类型的组件不能用,从这点看,很显然,class 类型组件将会走向消亡。

如何用Hooks 模拟旧版本的生命周期函数

Hooks 未来正式发布后, 我们自然而然的会遇到这个问题, 如何把写在旧生命周期内的逻辑迁移到Hooks里面来。下面我们就简单说一下,

模拟整个生命周期中只运行一次的方法

useMemo(() => {
  // execute only once
}, []);

我们可以看到useMemo 接收两个参数, 第一个参数是一个函数, 第二个参数是一个数组。

这里有个地方要注意, 就是, 第二个参数的数组里的元素和上一次执行useMemo的第二个参数的数组的元素 完全一样的话,那就表示没有变化, 就不用执行第一个参数里的函数了。 如果有不同, 说明有变化, 就执行。

上面的例子里, 我们只传入了一个空数组, 不会有变化, 也就是只会执行一次。

模拟shouldComponentUpdate

const areEqual = (prevProps, nextProps) => {
   // 返回结果和shouldComponentUpdate正好相反
   // 访问不了state
}; 
React.memo(Foo, areEqual);

模拟componentDidMount

useEffect(() => {
    // 这里在mount时执行一次
}, []);

模拟componentDidUpdate

const mounted = useRef();
useEffect(() => {
  if (!mounted.current) {
    mounted.current = true;
  } else {
    // 这里只在update是执行
  }
});

模拟componentDidUnmount

useEffect(() => {
    // 这里在mount时执行一次
    return () => {
       // 这里在unmount时执行一次
    }
}, []);

未来的代码形势

Hooks 未来发布之后, 我们的代码会写成什么样子呢? 简单设想一下:

// Hooks之后的组件逻辑重用形态

const XXXX = () => {
  const [xx, xxx, xxxx] = useX();

  useY();

  const {a, b} = useZ();

  return (
    <>
     //JSX
    </>
  );
};

内部可能用各种Hooks, 也可能包含第三方的Hooks。 分享Hooks 就是实现代码重用的一种形势。 其实现在已经有人在做这方面的工作了: useHooks.com, 有兴趣的朋友可以去看下。

Suspense 和 Hooks 带来的改变

Suspense 和 Hooks 发布后, 会带来什么样的改变呢? 毫无疑问, 未来的组件, 更多的将会是函数式组件。

原因很简单, 以后大家分享出来的都是Hooks,这东西只能在函数组件里用啊, 其他地方用不了,后面就会自然而然的发生了。

但函数式组件和函数式编程还不是同一个概念。 函数式编程必须是纯的, 没有副作用的, 函数式组件里, 不能保证, 比如那个resource.read(), 明显是有副作用的。

关于好坏 既然这两个东西是趋势, 那这两个东西到底好不好呢 ?

个人理解, 任何东西都不是十全十美。 既然大势所趋, 我们就努力去了解它,学会它, 努力用它好的地方, 避免用不好的地方。

React.memo

React.memo() 和 PureComponent 很相似,它帮助我们控制何时重新渲染组件。 为什么它被称作 memo? 它会检查接下来的渲染是否与前一次的渲染相同,如果两者是一样的,那么就会保留上一次的渲染结果。 由于 React.memo() 是一个高阶组件,你可以使用它来包裹一个已有的 functional component:

import React from 'react';

const MySnowyComponent = React.memo(function MyComponent(props) {
  // only renders if props have changed!
});

// can also be an es6 arrow function
const OtherSnowy = React.memo(props => {
  return <div>my memoized component</div>;
});

// and even shorter with implicit return
const ImplicitSnowy = React.memo(props => (
  <div>implicit memoized component</div>
));
cisen commented 5 years ago

个人理解

新context

  1. 使用const ThemeContext = React.createContext(globalObj),创建一个全局变量globalObj,类似全局store,大家都可以订阅globalObj的改变
  2. 通过<ThemeContext.Provider value={this.state}>的Provider指定子组件的ThemeContext.consume获取到ThemeContext全局变量,传入value将会覆盖createContext的时候创建的globalObj的值
  3. 通过

    <ThemeContext.Consumer>
      {theme => (
      <div><div>)
      }
    </ThemeContext.Consumer>

    订阅到ThemeContext的值。Consumer一定要放到Provider后面

hooks

useState

useEffect(() => { // 这里只有mount时才被调用,相当于componentWillUpdate }, random());


### useContext
搜索本页useContext的介绍,很好理解

## 编写组件的hooks
### redux
```js
// reducer
function todosReducer(state, action) {
  switch (action.type) {
    case 'add':
      return [...state, {
        text: action.text,
        completed: false
      }];
    // ... other actions ...
    default:
      return state;
  }
}
// actions.js
function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

这么使用

function Todos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  function handleAddClick(text) {
    dispatch({ type: 'add', text });
  }

  // ...
}

Form

// 封装
export function useFormState(initialState) {
   // useReducer可以使用上面的
  const [state, setState] = useReducer(stateReducer, initialState || {});

  const createPropsGetter = type => (name, ownValue) => {
    const hasOwnValue = !!ownValue;
    const hasValueInState = state[name] !== undefined;

    function setInitialValue() {
      let value = "";
      setState({ [name]: value });
    }

    const inputProps = {
      name, // 给 input 添加 type: text or password
      // get 是原生的
      get value() {
        if (!hasValueInState) {
          setInitialValue(); // 给初始化值
        }
        return hasValueInState ? state[name] : ""; // 赋值
      },
      // onChange是传给input触发的
      onChange(e) {
        let { value } = e.target;
        setState({ [name]: value }); // 修改对应 Key 的值
      }
    };

    return inputProps;
  };

  const inputPropsCreators = ["text", "password"].reduce(
    (methods, type) => ({ ...methods, [type]: createPropsGetter(type) }),
    {}
  );

  return [
    { values: state }, // formState
    inputPropsCreators
  ];
}

这么使用

// 当然先要导入
const [formState, { text, password }] = useFormState();
return (
  <form>
    <input {...text("username")} required />
    <input {...password("password")} required minLength={8} />
  </form>
);