探React Hooks

前言

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

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

明确的做用

它可让你在不编写 class 的状况下使用 state 以及其余的 React 特性。前端

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

动机

文档中列了三点:react

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

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

我对动机的理解

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

如今说利用hooks能够胜任class组件全部的能力。但你胜任归你胜任,我写class又有什么不能够。我继承、高阶骚的一逼,要啥hooks。编程

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

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

状态逻辑的复用

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

传统流派

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

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


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

掘金地址:相学长

相关文章
相关标签/搜索