众所周知,hooks在 React@16.8 中已经正式发布了。而下周周会,咱们团队有个同窗将会仔细介绍分享一下hooks。最近网上呢有很多hooks的文章,这难免激起了我本身的好奇心,想先行探探hooks到底好很差用。javascript
react hooks在其文档的最开头,就阐明了hooks的一个鲜明做用跟几个动机(或者说hooks的好处)。html
它可让你在不编写 class 的状况下使用 state 以及其余的 React 特性。前端
意思很明了,就是拓展函数式组件的边界。结果也很清晰,只要Class 组件能实现的,函数式组件+Hooks都能胜任。java
文档中列了三点:react
关于这三点的详细介绍文档里也有,还有中文的,我就很少说了。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的结构与样式有变动。唔.....仔细想一想有几种方案:
这也不行,那也很差。那用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。
若是对此仍是不理解,建议先看下相关文档。若是关于反作用不理解,能够到文章最后再看。在咱们当下的场景中,知道异步请求数据并更新组件内部状态值就属于反作用的一种便可。
知道基本概念之后,咱们看上述的代码,其实也大体能理解其机制。
list
,进而又触发第二次render。list
值,而不是初始值,进而页面渲染新的列表。至于react如何作到能数据的匹配,文档里有简单介绍。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层,进而更易阅读、维护、拓展。
不过不能开心的太早。作事若是浅尝则止,每每后续会遇到深坑。就以刚刚的需求来讲,有些特殊逻辑还未考察到。假如说,咱们的分页请求会失败,而页码已经更新,这该怎么办?通常来讲有几个思路:
那咱们就按方案3,暴露一个error的状态,提供一个刷新页面的方法。咱们忽然意识到一个问题,如何刷新页面数据呢?咱们的effect依赖于page变动,而刷新页面不变动page,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的相关文档,揣摩了一番,终于大体领悟。
这一点同咱们过去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);
// ...
};
复制代码
咱们能够明确知道两点不对的:
doInBottom
,所以,useEffect的依赖项至少应该填写 doInBottom
。固然,咱们也选择把 doInBootom
写到useEffect内部中,这样这个函数就成了内部引用,而不是外部依赖。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后,返回一个全新的状态值。具体用法能够见文档。其中主要记住两点:
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公式
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
本文做者:蚂蚁保险-体验技术组-阿相
掘金地址:相学长