这次的分享结合我在项目中使用 full hooks-based React Components 的一些经验,给大家介绍一些我所认为的 React Hooks 最佳实践。
文中的很多 term 是为了阐明一些概念所设,并非专有名词,不需要当真。
首先还是简单回顾一下 React Hooks。
先看传统的 React Class-based Component。一个组件由四部分构成:
React Hooks 组件其实可以简单地理解成一个 render 函数。这个 render 函数本身即组件。他通过 useState 和 useEffect 两个函数来实现函数的“状态化”,即获得对 state 和生命周期的注册和访问能力。
相比类组件,Hooks 组件有以下特点
基于上述迥异的语法和完全平行的 API,基于 Hooks 的组件书写可以被当作一门独立于基于类组件的全新框架。我们应尽量避免以模仿类组件的风格去书写 Hooks 组件的逻辑,而应当重新审视这种新的语法。
由于上述的语法特点,Hooks 适合通过「基于变更」的声明风格来书写,而非「基于回调」的命令式方式来书写。这会让一个组件更易于拆分和复用逻辑并拥有更清晰的逻辑依赖关系。大家将逐步看到「基于变更」的风格的优势,下面小举两个例子来对比一下「基于变更」和「基于回调」的写法:
需求场景:更改一个 keyword state 并发起查询的请求
基于回调的写法(仿类写法)
const Demo: React.FC = () => {
const [state, setState] = useState({
keyword: '',
});
const query = useCallback((queryState: typeof state) => {
// ...
}, []);
const handleKeywordChange = useCallback((e: React.InputEvent) => {
const latestState = { ...state, keyword: e.target.value };
setState(latestState);
query(latestState);
}, [state, query]);
return // view
}
这种写法有几个问题:
基于变更的写法
const Demo: React.FC = () => {
const [state, setState] = useState({
keyword: '',
});
const handleKeywordChange = useCallback((e: React.InputEvent) =>
{
const nextKeyword = e.target.value;
setState(prev => ({ ...prev, keyword: nextKeyword }))
}, []);
useEffect(() => {
// query
}, [state]);
return // view
}
上面的写法解决了「基于回调」写法的所有问题。它把 state 作为了 query 的依赖,只要 state 发生变更,query 就会自动执行,且执行时机一定是在 state 变更以后。我们没有命令式地调用 query,而是声明了在什么情况下它应当被调用。
当然这种写法也不是没有问题:
事实上,这个问题恰恰要求我们在写 Hooks 时花更多的精力专注于「变」与「不变」的管理,而不是「调」与「不调」的管理上。
需求场景:在 window resize 时触发 callback 函数
基于回调的写法(仿类写法)
const Demo: FC = () => {
const callback = // ...
useEffect(() => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}, []);
return // view
}
在「componentDidMount」的时候注册这个监听,在「componentWillUnmount」的时候注销它。很单纯啊是不是?
但是问题来了,在类组件中,callback 可以是一个类方法(method),它的引用在整个组件生命周期中都不会发生改变。但是函数式组件中的 callback 是在每次执行的上下文中生成的,它极有可能每次都不一样!这样 window 对象上挂载的监听将会是组件第一次执行产生的 callback,之后所有执行轮次中产生的 callback 都将不会被挂载到 window 的订阅者中,bug 就出现了。
那改一下?
const Demo: FC = () => {
const callback = // ...
useEffect(() => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}, [callback]);
return // view
}
这样把 callback 放到注册监听的 effect 的依赖中看起来似乎能 work,但是也太不优雅了。在组件的执行过程中,我们将疯狂地在 window 对象上注册注销注册注销,听起来就不太合理。下面看看基于变更的写法:
const Demo: FC = () => {
const [windowSize, setWindowSize] = useState([
window.innerWidth,
window.innerHeight
] as const);
useEffect(() => {
const handleResize = () => {
setWindowSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const callback = // ...
useEffect(callback, [windowSize]);
return // view
};
这里我们通过一个 useState 和一个 useEffect 首先把 window resize 从一个回调的注册注销过程转换成了一个表示 window size 的 state。之后依赖这个 state 的变更实现了对 callback 的调用。这个调用同样是声明式的,而不是直接手动命令式的调用的,而声明式往往意味着更好的可测性。
上面的代码看似更复杂了,但事实上,只要我们把 2-10 行的代码抽离出来,很快就得到了一个跨组件可复用的自定义 Hooks:useWindowSize。使得在别的组件中使用基于 window resize 的回调变得非常方便:
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState([window.innerWidth, window.innerHeight] as const);
useEffect(() => {
const handleResize = () => {
setWindowSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize
}
基于变更的写法的关键在于把「 动作」转换成「 状态」
通过上面的论述和例子我们可以看到在 Hooks-based 组件中合理地使用基于变更的代码可以带来一定的好处。为了更好地理解「基于变更」这件事。这里引入流式编程中常用于辅助理解的 Marble 图。你将很快发现,我们一直在说的「基于变更」于流式编程中的「流」没有两样:
RxMarble图例[1]
流式编程中,一个珠子(marble)就代表一个推送过来的数据,一串横向的珠子就代表一个数据流(Observable 或 Subject)在时间上的一系列推送数据。流式编程通过一系列操作符,对数据流实现加工整合映射等操作来实现编程逻辑。上图的 merge 操作,是非常常用的合并两个数据源的操作符。
基于变更的 Hooks coding 其实是与 stream coding 相当同构的概念。两者都弱化 callback,把 callback 包装起来成为流或操作符。
Hooks 组件中的一个 state 就是流式编程中的流,即一串珠子
而一个 state 的每一次变更,便是一颗珠子
为了完全地体现「变更」,所有的状态更新都要做到 immutable 简而言之:让引用的变化与值的变化完全一致
为了实现这一点,你可以:
(个人推荐 1 或 2,可以尽可能减少引入不必要的概念)
在 Hooks-based 编程中,我们还要有所谓「执行帧」的概念。这种概念在其他框架如 vue / Angular 中很被弱化,而对 React 尤其是函数式组件中却很有助于思考 在组件上下文中的 state 或 props 一旦发生变更,就会触发组件的执行。每次执行就相当于一帧渲染的绘制。所有的 marble 就串在执行帧与状态构成的网格中
对一个组件来说,能触发它重新渲染的变更称为「源」source。一个组件的变更源一般有以下几种:
上述源头,有些已经被「marble化」了,如 props。有些还没有,需要我们包装的方式把他们「marble 化」
const useClickEvent = () => {
const [clickEvent, setClickEvent] = useState<{ x: number; y: number; }>(null);
const dispatch = useCallback((e: React.MouseEvent) => {
setClickEvent({ x: e.clientX, y: e.clientY });
}, []);
return [clickEvent, dispatch] as const;
}
const useInterval = (interval: number) => {
const [intervalCount, setIntervalCount] = useState();
useEffect(() => {
const intervalId = setInterval(() => {
setIntervalCount(count => count + 1)
});
return () => clearInterval(intervalId);
}, []);
return intervalCount;
};
从源变更到最终 view 层需要的数据状态,一个组件的数据组织可以抽象成下图: 中间的 operators 就是组件处理数据的核心逻辑。在流式编程中的 operator 几乎都可以在 Hooks 中通过自定义 Hooks 写出同构的表示。
这些「流式 Hook」是由基本 Hooks 复合而成的更高阶的 Hooks,可以具有高度的复用性,使得代码逻辑更简练。
通过 useMemo 就可以直接实现把一些变更整合到一起得到一个「computed」状态
对应 ReactiveX 概念:map / combine / latestFrom
const [state1, setState1] = useState(initalState1);
const [state2, setState2] = useState(initialState2);
const computedState = useMemo(() => {
return Array(state2).fill(state1).join('');
}, [state1, state2]);
有时候我们不想在第一次的时候执行 effect 里的函数,或进行 computed 映射。可以实现自己实现的 useCountEffect / useCountMemo 来实现
对应 ReactiveX 概念:take / skip
const useCountMemo = <T>(callback: (count: number) => T, deps: any[]): T => {
const countRef = useRef(0);
return useMemo(() => {
const returnValue = callback(countRef.current);
countRef.current++;
return returnValue;
}, deps);
};
export const useCountEffect = (cb: (index: number) => any, deps?: any[]) => {
const countRef = useRef(0);
useEffect(() => {
const returnValue = cb(countRef.current);
currentRef.current++;
return returnValue;
}, deps);
};
在基于变更的 Hooks 组件中,debounce / throttle / delay 等操作变得非常简单。debounce / throttle / delay 的对象将不再是 callback 函数本身,而是变更的状态
对应 ReactiveX 的概念:debounce / delay / throttle
const useDebounce = <T>(value: T, time = 250) => {
const [debouncedState, setDebouncedState] = useState(null);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedState(value);
}, time);
return () => clearTimeout(timer);
}, [value]);
return debouncedState;
};
const useThrottle = <T>(value: T, time = 250) => {
const [throttledState, setThrottledState] = useState(null);
const lastStamp = useRef(0);
useEffect(() => {
const currentStamp = Date.now();
if (currentStamp - lastStamp > time) {
setThrottledState(value);
lastStamp.current = currentStamp;
}
}, [value]);
return throttledState
}
Redux 的核心架构 action / reducer 模式在 Hooks 中的实现非常简单,React 甚至专门提供了一个经过封装的语法糖钩子 useReducer 来实现这种模式。
对于异步流程,我们同样可以采用 action / reducer 的模式来实现一个 useAsync 钩子来帮助我们处理异步流程。
这里示意的是一个最简单的基于 promise 的函数模式,类似 redux 中使用 redux-thunk 中间件。
同时,我们伴随请求的数据状态维护一组 loading / error / ready 字段,用来标示当前数据的状态。
useAsync 钩子还可以内置对多个异步流程的 竞争 / 保序 / 自动取消 等机制的控制逻辑。
下面示例了 useAsync 钩子的用法,采用了 generator 来实现一个异步流程对状态的多步修改。甚至可以实现类似 redux-saga 的复杂异步流程管理。
const responseState = useAsync(responseInitialState, actionState, function * (action, prevState) {
switch (action?.type) {
case 'clear':
return null;
case 'request': {
const { data } = yield apiService.request(action.payload);
return data;
}
default:
return prevState;
}
})
下面的代码例举了一个通过类「action/ reducer」模式的异步钩子来维护一个字典类型的数据状态的场景:
// 来自 props 或 state 的 actions
// fetch action: 获取
let fetchAction: {
type: 'query',
id: number;
};
let clearAction: {
type: 'clear',
ids: number[]; // 需要保留的 ids
}
let updateAction: {
type: 'update',
id: number;
}
// 通过一个自定义的 merge 钩子来保留上述三个状态中最新变更的一个状态
const actions = useMerge(fetchAction, clearAction, updateAction);
// reducer
const dataState = useQuery(
{} as Record<number, DataType>,
actions,
async (action, prev) => {
switch (action?.type) {
case 'update':
case 'query': {
const { id } = action;
// 已经存在子列表的情况下,不对数据作变更,返回一个 identity 函数
if (action.type === 'query' && prev[id]) return prevState => prevState;
// 拉取指定 id 下的列表数据
const { data } = await httpService.fetchListData({ id });
// 返回一个插入数据的状态映射函数
return prev => ({
...prev,
[id]: data,
});
}
case 'clear': {
// 返回一个保留特定 id 数据的状态映射函数
return prev =>
pick( // pick 是一个从对象里获取一部分 key value 对组成新对象的方法
prev,
action.ids,
);
}
default:
return prev;
}
},
{ mode: 'multi', immediate: false }
);
通过 Hooks 管理全局状态可以与传统方式一样,例如借助 context 配合 redux 通过 Provider 来下发全局状态。这里推荐更 Hooks 更方便的一种方式——单例 Hooks:Hox[3]
通过第三方库 Hox 提供的 createModel 方法可以产生一个挂载在虚拟组件中的全局单例的 Hooks。这个虚拟组件的实例一经创建将在 app 的整个生命周期中存活,等于是产生了一个全局的「marble 源」,从而任何的组件都可以使用这个 Hooks 来获取这个源来处理自己的逻辑。
hox 的具体实现涉及自定义 React Reconciler,感兴趣的同学可以去看一下它源码的实现。
「基于变更」的 Hooks 组件书写由于与流式编程非常相似,我也把他称作「流式 Hooks」。
上面介绍了很多流式 Hooks 的好处。通过合适的逻辑拆分和复用,流式 Hooks 可以实现非常细粒度且高内聚的代码逻辑。在长期实践中也证明了它是比较易于维护的。那么这种风格 Hooks 存在什么局限性呢?
在 React 中,存在三种不同「帧率」或「频繁度」的东西:
这三者的触发频率是从上至下越来越高的
由于 React Hooks 的变更传播的最小粒度是「执行帧」粒度,故一旦事件的发生频率高过它(一般来说只会是同步的多次事件的触发),这种风格的 Hooks 就需要一些较为 Hack 的逻辑来兜底处理。
流式编程如 RxJS 大量被用于消息通讯(如在 Angular 中),被用于处理复杂的事件流程。但其本身一直没有成为主流的应用架构。导致这个状况的一个瓶颈就在于它几乎没有办法写一星半点命令式的代码,从而会出现把一些通过命令式/回调式很好实现的代码写得非常冗长难懂的情况。
React Hooks 虽然可以与 RxJS 的语法产生很大成都的同构,但其本质仍然是命令式为底层的编程,故它可以是多范式的。在编码中,我们在绝大部分场景下可以通过流式的风格实现,但也应当避免为了流而流。如 Redux 下的一个关于哪些状态应该放到全局哪些应该放到组件内的 Issue 下评论的:选择看起来更不奇怪(less weird)的那个
目前我正在规划和产出一套基础的流式 Hooks,便于业务逻辑引用来书写具有流式风格的 Hooks 代码Marble Hooks[4]
[1]RxMarble图例: https://rxmarbles.com/
[2]ImmutableJS: https://immutable-js.github.io/immutable-js/
[3]Hox: https://github.com/umijs/hox
[4]Marble Hooks: https://github.com/pierrejacques/marble-hooks
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/BXwMmOzbWHbeXkBYNJebXw
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。