阅读 1804

探React Hooks

前言

众所周知,hooks在 React@16.8 中已经正式发布了。而下周周会,我们团队有个同学将会仔细介绍分享一下hooks。最近网上呢有不少hooks的文章,这不免激起了我自己的好奇心,想先行探探hooks到底好不好用。

react hooks在其文档的最开头,就阐明了hooks的一个鲜明作用跟几个动机(或者说hooks的好处)。

明确的作用

它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

意思很明了,就是拓展函数式组件的边界。结果也很清晰,只要Class 组件能实现的,函数式组件+Hooks都能胜任。

动机

文档中列了三点:

  1. 在组件之间复用状态逻辑很难;
  2. 复杂组件变得难以理解;
  3. class让开发人员与计算机都难理解;

关于这三点的详细介绍文档里也有,还有中文的,我就不多说了。

我对动机的理解

动机也即是hooks能带来的好处。其中第三点,文档中所说class的弊端对于我本人,还是有点儿不痛不痒。this的问题,箭头函数解决的差不多了;语法提案也到stage-3了;代码压缩什么的,自己的资源代码大小往往不是核心问题。

现在说利用hooks可以胜任class组件所有的能力。但你胜任归你胜任,我写class又有什么不可以。我继承、高阶骚的一逼,要啥hooks。

然而第1、2两点还是吸引了我的注意。状态逻辑的复用,之前我主要采用高阶组件+继承,虽然也能解决,但hooks似乎有更优雅的方案。复杂组件变得难以理解,这个也确实是平常中遇到的问题,一个组件写着写着状态越来越多,抽成子组件吧props跟state又传来传去。三个月后,自己的代码自己已经看不懂了。

那hooks真的就能更好的解决这些问题么?文档里轻飘飘的几句话,对于实际业务来说,确实没有太多体感。于是我决定简单写几个场景,探一探这hooks的活到底好不好。

状态逻辑的复用

这种场景其实挺常见。只要页面中有需要复用的组件,且这个组件又有较为复杂的状态逻辑,就会有这样的需求。举个例子:中后台系统常见的各种列表,表格内容各不相同,但是都要有分页的行为,于是分页组件就需要去抽象。按照正常的写法,我们会怎么做呢?

传统流派

最开始,我们可能不会想着通用,就写一个列表+分页的组件。以最简单的分页为例,可能会如下写(为方便阅读,不做太多异常处理):

import { Component } from 'react';
import { range } from 'lodash';

// 模拟列表数据请求
const fetchList = ({ page = 1, size = 10 }) =>
  new Promise(resolve => resolve(range((page - 1) * size, page * size)))

export default class ListWithPagination extends Component {
  state = {
    page: 1,
    data: [],
  }

  componentDidMount() {
    this.fetchListData(this.setState);
  }

	handlePageChange = newPage =>
  	this.setState({ page: newPage }, this.fetchListData)

  fetchListData = () => {
    const { page } = this.state;
    fetchList({ page }).then(data => this.setState({ data }));
  }
  
  render() {
    const { data, page } = this.state;
    return (
      <div>
        <ul className="list">
          {data.map((item, key) => (
            <li key={key}>{item}</li>
          ))}
        </ul>
        <div className="nav">
          <button type="button" onClick={() => this.handlePageChange(page - 1)}>
            上一页
          </button>
          <label>当前页: {page}</label>
          <button type="button" onClick={() => this.handlePageChange(page + 1)}>
            下一页
          </button>
        </div>
      </div>
    );
  }
}
复制代码

然后我们就会想,每个地方都要有分页,唯一不太一样的仅是 列表渲染 跟数据请求api而已,那何不抽个高阶组件呢?于是代码变成了:

export default function ListHoc(ListComponent) {
  return class ListWithPagination extends Component {
    // ...同上述code,省略

    // 数据请求方法,从props中传入
    fetchListData = () => {
      const { fetchApi } = this.props;
      const { page } = this.state
      return fetchApi({ page }).then(data => this.setState({ data }));
    }

    render() {
      const { data, page } = this.state;
      return (
        <div>
          <ListComponent data={data} />
          <div className="nav">...省略</div>
        </div>
      );
    }
  };
}
复制代码

这么一来,未来再写列表时,使用高阶组件包裹一下,再把数据请求方法 以props传入,就能达到一个复用状态逻辑与分页组件的效果了。

就在我们得意之际,又来了一个新需求,说有一个列表的分页导航,需要在 列表上面,而不是 列表下面,换成程序语言意思就是Dom的结构与样式有变更。唔.....仔细想想有几种方案:

  • 传递一个props叫“theme”,控制不同的顺序跟样式....乍一看还行,但如果未来两种列表风格越来越远,这个高阶组件会越来越重....不行不行。
  • 再写一个类似的高阶组件,dom结构不一样,但其他一模一样。唔,代码重复度这么高,真low,不行不行。
  • 再写一个组件,继承这个这个高阶组件,重写render。好像还可以,就是这个继承关系略略有点儿奇怪,应该是兄弟关系,而不是继承关系。当然我可以再抽象一层包含状态逻辑处理的通用Component,两种列表形式的高阶组件都是继承它,而不是继承 React.Component。但是即使如此,通过继承来复写render的方式,无法清晰感知组件到底有哪些状态值,尤其在状态较多,逻辑较为复杂的情况下。这样日后维护,或者拓展render时,就举步维艰。

这也不行,那也不好。那用hooks来做又能做成哪样呢?

Hooks流派

注:为了简化,下文中的 effect 都指代 side effect。

尝试改造

首先,我们把最开始那个 ListWithPagination 以hooks改写,那就成了:

import { useState, useEffect } from 'react';
import { range } from 'lodash';

// 模拟列表数据请求
const fetchList = ({ page = 1, size = 10 }) =>
	new Promise(resolve => resolve(range((page - 1) * size, page * size)));

export default function List() {
  const [page, setPage] = useState(1); // 初始页码为: 1
  const [list, setList] = useState([]); // 初始列表数据为空数组: []

  useEffect(() => {
    fetchList({ page }).then(setList);
  }, [page]); // 当page变更时,触发effect

  const prevPage = () => setPage(currentPage => currentPage - 1);
  const nextPage = () => setPage(currentPage => currentPage + 1);

  return (
    <div>
      <ul>
        {list.map((item, key) => (
          <li key={key}>{item}</li>
        ))}
      </ul>
      <div>
        <button type="button" onClick={prevPage}>
          上一页
        </button>
        <label>当前页: {page}</label>
        <button type="button" onClick={nextPage}>
          下一页
        </button>
      </div>
    </div>
  );
}

复制代码

为防止部分同学不理解,我再简单介绍下 useState 与 useEffect。

  • useState: 执行后,返回一个数组,第一个值为状态值,第二个值为更新此状态值的对应方法。useState函数入参为state初始值。
  • useEffect:执行副作用操作。第一个参数为副作用方法,第二个参数是一个数组,填写副作用依赖项。当依赖项变了时,副作用方法才会执行。若为空数组,则只执行一次。如不填写,则每次render都会触发。

如果对此还是不理解,建议先看下相关文档。如果关于副作用不理解,可以到文章最后再看。在我们当下的场景中,知道异步请求数据并更新组件内部状态值就属于副作用的一种即可。

知道基本概念以后,我们看上述的代码,其实也大致能理解其机制。

  1. 组件初始化也即第一次render后,会触发一次effect,请求第一页数据后,更新列表数据 list ,进而又触发第二次render。
  2. 在第二次render中,useState会获取当前的 list 值,而不是初始值,进而页面渲染新的列表。至于react如何做到能数据的匹配,文档里有简单介绍
  3. 在后续的用户点击行为中,触发了setPage,进而更新了 page ,由于它的变更触发了effect,effect执行后又更新 list ,触发新的render,渲染最新的列表。

在了解机制以后,我们就要开始做正经事了。上述传统流派中,通过高阶组件抽象公共逻辑。现在我们通过hooks改造了最初的class组件。下一步应该抽离状态逻辑。类似刚刚高阶组件的结果,我们期望将分页的行为抽离,那太简单了,把处理状态的相关代码封装成函数,抽离出组件,再传递一下数据请求api就好:

// 传递获取数据api,返回 [当前列表,分页数据,分页行为]
const usePagination = (fetchApi) => {
  const [page, setPage] = useState(1); // 初始页码为: 1
  const [list, setList] = useState([]); // 初始列表数据为空数组: []

  useEffect(() => {
    fetchApi({ page }).then(setList);
  }, [page]); // 当page变更时,触发effect

  const prevPage = () => setPage(currentPage => currentPage - 1);
  const nextPage = () => setPage(currentPage => currentPage + 1);

  return [list, { page }, { prevPage, nextPage }];
};

export default function List() {
  const [list, { page }, { prevPage, nextPage }] = usePagination(fetchList);
  return (
    <div>...省略</div>
  );
}

复制代码

如果你希望分页的dom结构也想复用,那就再抽个函数便好。

function renderCommonList({ ListComponent, fetchApi }) {
  const [list, { page }, { prevPage, nextPage }] = usePagination(fetchApi);
  return (
    <div>
      <ListComponent list={list} />
      <div>
        <button type="button" onClick={prevPage}>
          上一页
        </button>
        <label>当前页: {page}</label>
        <button type="button" onClick={nextPage}>
          下一页
        </button>
      </div>
    </div>
  );
}

export default function List() {
  function ListComponent({ list }) {
    return (
      <ul>
        {list.map((item, key) => (
          <li key={key}>{item}</li>
        ))}
      </ul>
    );
  }
  return renderCommonList({
    ListComponent,
    fetchApi: fetchList,
  });
}
复制代码

如果你希望有一个新的分页结构与样式,那就重写一个结构,并引用 usePagination 。总之,最核心的状态处理逻辑已经被我们抽离出来,因为无关this,于是它与组件无关、与dom也可以无关。爱插哪插哪,谁爱用谁用。百花丛中过,片叶不沾身。

这么一来,数据层与dom更加的分离,react组件更加的退化成一层UI层,进而更易阅读、维护、拓展。

场景深入

不过不能开心的太早。做事如果浅尝则止,往往后续会遇到深坑。就以刚刚的需求来说,有些特殊逻辑还未考察到。假如说,我们的分页请求会失败,而页码已经更新,这该怎么办?一般来说有几个思路:

  1. 请求失败以后回滚页码。但实现不优雅,且页码跳来跳去,放弃。
  2. 数据请求成功以后再更新页码。比较适合移动端滚动加载的情况。
  3. 不回滚页码,列表页提示异常,点击触发重试。比较适合上述中分页列表的情况。

那我们就按方案3,暴露一个error的状态,提供一个刷新页面的方法。我们突然意识到一个问题,如何刷新页面数据呢?我们的effect依赖于page变更,而刷新页面不变更page,effect便不会触发。想一下,也有两个思路:

  1. 再加一个关于刷新的状态值,刷新页面数据的方法,每次执行都会为其+1,触发effect。不过这样会导致组件平白无故加个状态值。
  2. 依赖项改为一个对象,page为对象中一个属性,日后也方便拓展。由于对象无法对比的特性,每次setState都会触发effect。不过有可能会导致数据无意义的重复获取,比如快速点击同一个页码时,触发了两次数据获取。

综合考虑来说,我采取第二个方案。因为effect强依赖于入参的变更也不合理,毕竟这是一个有副作用的方法。相同的分页入参下,服务端也有可能返回不同的结果。数据重复获取的问题,可以手动加入防抖等手段优化。具体代码如下:

const usePagination = (fetchApi) => {
  const [query, setQuery] = useState({ page: 1, size: 15 }); // 初始页码为: 1
  const [isError, setIsError] = useState(false); // 初始状态为false
  const [list, setList] = useState([]); // 初始列表数据为空数组: []
  
  useEffect(() => {
    setIsError(false);
    fetchApi(query)
    .then(setList)
    .catch(() => setIsError(true));
  }, [query]); // 当页面查询参数变更时,触发effect
  
  const { page, size } = query;
  const prevPage = () => setQuery({ size, page: page - 1 });
  const nextPage = () => setQuery({ size, page: page + 1 });
  const refreshPage = () => setQuery({ ...query });
  
	// 如果数据过多,数组解构麻烦,也可以选择返回对象
  return [list, query, { prevPage, nextPage, refreshPage }, isError];
};
复制代码

但是如果按照方案2呢?「数据请求成功以后再更新页码」。在移动端的长列表滚动加载时,页面并不透出页码,滚动加载失败时,toast提示失败,再滚动依旧加载刚刚失败的那一页。然而在我们的 usePagination 中,数据请求的effect必须是通过query变更来触发的,无法实现请求结束以后再更改页码。如果是通过方案1「请求失败以后回滚页码」,那由于回滚了页面,又会触发一次effect请求,这也不是我们想看到的。

其实这是钻了牛角尖,这本身已经是不同的场景了。在移动端的滚动加载中,是否加载并非是由“页码变更”控制,而是由“是否滚动到底部”控制。于是代码应该是:

// 滚动到底部时,执行
const useBottom = (action, dependencies) => {
  function doInBottom() {
    // isScrollToBottom 的代码不贴了
    return isScrollToBottom() && action();
  }
  useEffect(() => {
    window.addEventListener('scroll', doInBottom);
    // 组件卸载或函数下一次执行时,会先执行上一次函数内部return的方法
    return () => {
      window.removeEventListener('scroll', doInBottom);
    };
  }, dependencies);
};

const usePagination = (fetchApi) => {
  // 因为每次是请求下一页数据,所以现在初始页码为: 0
  const [query, setQuery] = useState({ page: 0, size: 50 });
  const [list, setList] = useState([]); // 初始列表数据为空数组: []

  const fetchAndSetQuery = () => {
    // 每次请求下一页数据
    const newQuery = {
      ...query,
      page: query.page + 1,
    };
    fetchApi(newQuery)
    .then((newList) => {
      // 成功后插入新列表数据,并更新最新分页参数
      setList([...list, ...newList]);
      setQuery(newQuery);
    })
    .catch(() => window.console.log('加载失败,请重试'));
  };
  // 首次mount后触发数据请求
  useEffect(fetchAndSetQuery, []);
  // 滚动到底部触发数据请求
  useBottom(fetchAndSetQuery);

  return [list];
};
复制代码

其中在 useBottom 内的effect函数中,返回了一个解绑滚动事件的函数,在组件卸载或者下一次effect触发时,会先执行此函数进行解绑行为。在传统的class组件中,我们一般是在unmount阶段去解绑事件。如果副作用依赖了props或state,在update阶段可能也需要清除老effect,执行新effect。如此一来,处理统一逻辑的函数就被分散在多个地方,导致组件复杂度的上升。

另外眼尖的同学会发现,为什么useBottom内部的useEffect的依赖项,在我们这个场景中不设置呢?滚动事件,不是应该mount的时候初始化就好了吗?按之前的理解,应该是写一个空数组[],这样滚动事件只绑定一次。然而如果我们真的这样写: useBottom(fetchAndSetQuery, []) 的话,就会发现一个大bug。 fetchAndSetQuery 中的query与list 永远都是初始化时的数据,也即是 { page: 0, size: 50 } 与 [] 。结果就是每次滚动到底部,加载的还是第一页数据,渲染的也还是第一页数据([...[], ...第一页数据])。

Why!!!

于是我又阅读了一次uesEffect的相关文档,揣摩了一番,终于大致领悟。

useState与useEffect的正确使用姿势

state永远都是新的值

这一点同我们过去class组件中的state是完全不一样的。在class组件中,state一直是挂载在当前实例下,保持着同一个引用。而在函数式组件中,根本没有this。不管你的state是一个基本数据类型(如string、number),还是一个引用数据类型(如object),只要是通过useState获取的state,每一次render,都是新的值。 useState返回的状态更新方法,只是让下一次render时的state能获取到当前最新的值。而不是保持一个引用、更新那个引用值。(这一段如果看不懂,就多看几遍,如果还看不懂,请评论区温柔的指出,我想想再怎么通俗的去解释)

读懂这个概念,并把这个概念作为hooks使用的第一准则后,我们就能清晰的明白,为什么上述代码中,如果useBottom中的useEffect的依赖项设为空数组,则内部的state,也即query与list,永远都是初始值。因为设为空数组后,其内部的 useEffect 中的滚动监听函数 内执行的 fetchAndSetQuery函数,其内部的query与list,也一直是第一次render时 useState 返回的值。

而如果不是空数组,每次render后,useBottom 中的滚动监听函数,会重新解绑旧函数,绑定新函数。新的函数带来的是 最新一次render时,useState 返回的最新状态值,故而实现正确的逻辑。

正确认识依赖项

于是我们更能深刻的认识到,为什么useEffect的依赖项设置如此重要。其实并非是设置依赖项后,依赖变更会触发effect。而是effect本应该每次render都触发,但因为effect内部依赖了外部数据,外部数据不变则内部effect执行无意义。因此只有当外部数据变更时,effect才会重新触发。

所以科学的来说,只要内部使用了某个外部变量,函数也好、变量也好,都应该填写到依赖配置中。所以我们上述编写的 useBottom 与使用方法其实并不严谨,我们再review一遍:

const useBottom = (action, dependencies) => {
  function doInBottom() {
    // isScrollToBottom 的代码不贴了
    return isScrollToBottom() && action();
  }
  useEffect(() => {
    window.addEventListener('scroll', doInBottom);
    // useEffect内部return的方法,会在下一次render时执行
    return () => {
      window.removeEventListener('scroll', doInBottom);
    };
  }, dependencies);
};

const usePagination = (fetchApi) => {
  // ...
  useBottom(fetchAndSetQuery);
  // ...
};
复制代码

我们可以明确知道两点不对的:

  1. 在这个场景中,useEffect明确依赖了doInBottom ,因此,useEffect的依赖项至少应该填写 doInBottom 。当然,我们也选择把 doInBootom 写到useEffect内部中,这样这个函数就成了内部引用,而不是外部依赖。
  2. action 是一个未知的函数,其内部可能包含了外部依赖,我们传递的 dependencies 应该是满足action 的明确依赖的,而不是自己瞎想到底是不填还是空数组。当然,更粗暴的方法是,直接把 action 作为依赖项。

所以最终科学的代码应该是:

const useBottom = (action) => {
  useEffect(() => {
    function doInBottom() {
      return isScrollToBottom() && action();
    }
    window.addEventListener('scroll', doInBottom);
    return () => {
      window.removeEventListener('scroll', doInBottom);
    };
  }, [action]);
};

const usePagination = (fetchApi) => {
  // ...
  useBottom(fetchAndSetQuery);
  // ...
};

复制代码

偏要勉强

还是有些同学,不喜欢这个依赖项,嫌传来传去的太麻烦,那有没有办法不传?还是有一些办法的。

首先,useState 返回的setState可以接受一个函数,函数的入参即是当前最新的状态值。在刚刚滚动加载的例子中,就可以避免了 list 成为副作用的依赖。不过 query 依旧没办法,因为请求数据需要最新状态值。但如果我们每一页数据的数量是固定的,我们可以把页码状态封装在请求方法里,如:

// 利用闭包维持分页状态
const fetchNextPage = ({ initPage, size }) => {
  let page = initPage - 1;
  return () => fetchList({ page: page + 1, size }).then((rs) => {
    page += 1;
    return rs;
  });
};
复制代码

然后我们的 useBottom 可以真的不管关心依赖了,只需要第一次render时绑定滚动事件即可,代码如下:

const useBottom = (action, dependencies) => {
  useEffect(() => {
    function doInBottom() {
      return isScrollToBottom() && action();
    }
    window.addEventListener('scroll', doInBottom);
    return () => {
      window.removeEventListener('scroll', doInBottom);
    };
  }, dependencies);
};

const usePagination = (fetchApi) => {
  const [list, setList] = useState([]); // 初始列表数据为空数组: []

  const fetchData = () => {
    fetchApi()
    .then((newList) => {
      setList(oldList => [...oldList, ...newList]);
    })
    .catch(() => window.console.log('加载失败,请重试'));
  };
  useEffect(fetchData, []);
  useBottom(fetchData, []);

  return [list];
};

export default function List() {
  const [list] = usePagination(fetchNextPage({ initPage: 1, size: 50 }));
  return (...略);
}
复制代码

其实useState返回的setState还有一个小弊端。如果页面状态较多,在某些异步行为(请求、定时器等)的回调中的setState是不会合并更新的(具体可自行研究react状态更新事务机制)。那分散的setState会带来多次render,这必然不是我们想看到的。

解决办法就是 useReducer ,其执行后返回 [state, dispatch] ,基本类似redux中的reducer。其中state是复杂状态的合集,dispatch触发reducer后,返回一个全新的状态值。具体用法可以见文档。其中主要记住两点:

  1. dispatch本身是稳定的,不会随多次render而导致变化,且dispatch触发的reducer函数,其入参的state始终是当下最新值。所以若是新状态的设置依赖于旧状态值,通过dispatch来更新,也可以避免effect依赖外部state。
  2. useReducer 返回的state(并非reducer函数中的入参state),依旧遵循useState那套逻辑,每次render中获取的都是全新值而非同一个引用。

既然有了useReducer,那有没有 useRedux 呢?抱歉,并没有。不过 Redux 目前已有issue在讨论其hooks的实现了。也有外国网友做了一个简版的 useRedux,实现机制也非常简单,自己也能维护。如果有全局状态管理的需求,也可以做一下代码的搬运工。

相信在19年,将会有很多基于hooks的工具甚至是hooks库的出现。通过对状态逻辑的抽象、更方便的状态管理、更科学的函数组合与拆分,最开始所说的动机第二点「难以理解的复杂组件」在将来可能真的可以更好的避免。

总结

探到这里,我个人对hooks已经基本有个数了。它脱离了我传统的class组件开发方式,对state的定义也不同于组件中的this.state,对effect的概念与处理需要更加清晰明了。

使用hooks的明显好处是可以更好的抽象包含状态的逻辑,隐藏的一些功能是基于hooks的各种花式轮子。当然其“不好的地方”是有一个明显的认知与学习成本,如果写的不好,更容易出现性能问题。整体而言,这虽然比不上几年前 直接操作dom 跃迁到** 数据驱动DOM** 这样的革命性变更,但确实是react内部明显的革命性成就。

不知道各位看完以后,未来是倾向于 函数式组件+hooks 还是倾向于 class组件。可以在评论区进行一下小投票。就我个人而言,我站hooks。

关于React副作用

有些同学可能会对「副作用」这个概念不理解。我简单的说一下我的看法。很多人都看过一个React公式

UI = F(props)

翻译成普通话就是:一个组件最终的dom结构与样式是由父级传递的props决定的。

了解过函数式编程的同学,应该知道过一个概念,叫「纯函数」。意思是固定的输入必然有固定的输出,它不依赖任何外部因素,也不会对外部环境产生影响。

react希望自己的组件渲染也是个纯函数,所以有了纯函数组件。然而真正的业务场景是有各种状态的,实际影响UI的还有内部的state。(其实还有context,暂时先不讨论)。

UI = F(props, state, context)

这个state可能会因为各种原因产生变化,从而导致组件的渲染结果不一致。相同的入参(props)下,每次render都有可能返回不同的UI。因此任何导致此现象的行为都是副作用(side effects)。比如用户点击下一页,导致页码与列表发生变化,这就是副作用。同样的props,不点击时是第一页数据,点击一下后,变成了第二页的数据or请求失败的页面or其他UI交互。

当然state是明面上影响了UI,暗地里,可能还有其他因素会影响UI。比如组件内运用了缓存,导致每次渲染可能都不一样,这也是副作用。


关于我们:

我们是蚂蚁保险体验技术团队,来自蚂蚁金服保险事业群。我们是一个年轻的团队(没有历史技术栈包袱),目前平均年龄92年(去除一个最高分8x年-团队leader,去除一个最低分97年-实习小老弟)。我们支持了阿里集团几乎所有的保险业务。18年我们产出的相互宝轰动保险界,19年我们更有多个重量级项目筹备动员中。现伴随着事业群的高速发展,团队也在迅速扩张,欢迎各位前端高手加入我们~

我们希望你是:技术上基础扎实、某领域深入(Node/互动营销/数据可视化等);学习上善于沉淀、持续学习;性格上乐观开朗、活泼外向。

如有兴趣加入我们,欢迎发送简历至邮箱:fengxiang.zfx@antfin.com


本文作者:蚂蚁保险-体验技术组-阿相

掘金地址:相学长

关注下面的标签,发现更多相似文章
评论