【React】从class componet到hooks component,一个只有局部最优解的时代
团队使用react hooks差不多有半年了,回顾这半年,看着团队一点点的生产的一个个hook,让笔者想起了那个react刚刚横空出世的年代。
应该是在2016年的时候,笔者的团队还在使用以backbone为核心的前端架构,每一个新的组件,前端都需要花费大量的精力在建立数据与视图的关系以及用户输入与数据的关系上,那是一种类似无定向数据流的模型。但随着业务场景的日益复杂,复杂的数据流向给前端应用的维护带来了极大的挑战,组件状态依赖于纷繁复杂的数据流的计算结果。。
而正当笔者的团队在纠结该如何解决一个又一个这样的问题时。。大洋彼岸,一个以“单向数据流”为特点的库开始悄然崛起,它就是——React。
相较于react的其他特性,单向数据流,无疑是最吸引笔者的特性。这种数据流思想引入之后,开发者再也无须考虑复杂的数据流向,只需要统一对来自组件上层或组件自身的数据做出处理即可,极大的提高了数据处理的效率,再加上jsx、virtual dom等特性的加持,react顺应时代的潮流成为了最流行的前端库。
不过,没什么方案是完美的,react也一样,但令笔者始料不及的是,它会以这样一种方式展现在笔者眼前。。
import React, { Component } from "react";
export default class App extends Component{
constructor(props) {
super(props);
this.state = { a: 1 };
}
componentDidMount() {
this.setState({ a: 2 }, () => {
this.state.a = 3;
})
}
render() {
return <div>{this.state.a}</div>
}
}
其实,了解React机制的同学都知道,上段代码中标红的部分其实是React本身不推荐的写法,如果项目中有使用eslint,还会提示【Do not mutate state directly】,是一种极不安全的写法(聪明的你不妨也想想最后会渲染出多少),这是React基于js实现所做出的让步,本身是一种无奈之举。。但是实际操作时,却会产生远超我们想象的副作用:
componentDidMount() { this.setState({ a: 2 }, () => { // eslint-disable-next-line this.state.a = 3; setTimeout(() => { this.setState({a: this.state.a}); }, 1000); }) }
为了规避eslint的检测,就有了直接禁用下一行eslint的操作,而为了让state设置生效,则又刻意避开batchupdate的setState。。
由于近年来前端整个行业的迅速发展特性,很多从业者的团队基本上都是被半捆绑式的硬上React的,他们有的精通jQuery,有的精通angular,有的精通backbone,但却对React知之甚少。。于是,最终就有了上文那啼笑皆非的局面。
但是,如果抛开那些外因不说,单单从工程化的层面来看待这个问题,却又有一个问题摆在笔者面前:React这样的设计是不是不太合理?
而命运就是这样么的凑巧,虽然React团队可能并不是专门想解决这个问题,不过随着hooks的到来,新的方式在书写上就直接提示你要使用setter而不是value去进行赋值操作,如下代码示意:
import React, { useState, useEffect } from "react"; export default function App() { const [state, setState] = useState(1); useEffect(() => { setState(2); }, [state]); return <div>{state}</div>; }
得益于新的写法建议是state value而不是整个state object,同时显式的setState暴露,也间接避免了这个问题。不过,笔者还没高兴多久,新的问题又产生了,而且,这次的现象,甚至有些让人怀疑自己学习的js是否正确:
import React, { useState, useEffect } from "react"; export default function App() { const [state, setState] = useState(1); const add = () => setInterval(() => setState(state + 1), 1000); useEffect(add, []); return <div>{state}</div>; // state always be 2 }
如上述代码所示,在"didmount"之后,会以1秒为周期,对state进行自增,但是实际情况是,除了第一次自增(为2)有效之外,这个setInterval仿佛变成了setTimeout一样,只执行了一次?正常的理解是,每次执行的时候,add引用外部的state进行自增,然后进行复制。但是,在当前的React中,因为存在capture value的特性(官方给出的解释是:This prevents bugs caused by the code assuming props and state don’t change)所以,变成了每次执行时,add会生成一个新的闭包,而每个闭包都引用自己生成时的变量,而它们都是在state为1时生成的,也就导致了state的值,永远都在重复由1变到2的过程,所以,才有了视觉上,只进行了一次interval的行为。
另外,因为function compoent会产生大量的调用,add通常还会被写作形如
const add = useCallback(() => setInterval(() => setState(state + 1), 1000),[count]);
这样做的好处是避免重复生成新的add实例,其实就是闭包或者函数的复用,在这个场景下其实更容易触发Capture Value的机制。
那么,应该如何解决这种问题呢?
官方给出了一套方案是使用ref,对,在hooks中,ref的场景得到了增强:
import React, { useState, useEffect, useCallback, useRef } from "react"; export default function App() { const [state, setState] = useState(1); const countRef = useRef(null); const add = useCallback( () => setInterval(() => { if (!countRef.current) { countRef.current = 1; } else { countRef.current += 1; } setState(countRef.current); }, 1000), [] ); useEffect(add, []); return <div>{state}</div>; }
其实,就是官方为ref建立了一个能够实时获取更新的引用关系,让它能够实时的拿到引用的值,而不受capture value的限制。不过,不管怎么说,在易用性上,react此次的进化依然显得不是那么令人满意,虽然hooks提高了对业务逻辑复用复用性,但是原子化的state带来的大量模板代码,循环中的hooks的各种限制,还有这个capture value。。不得不说,hooks的前方路漫漫其修远兮。
扯得有点远,其实笔者里例子有很简单的实现方式,都不需要用到interval:
import React, { useState, useEffect } from "react"; export default function App() { const [state, setState] = useState(1); useEffect(() => { setTimeout(() => setState(state + 1), 1000); }, [state]); return <div>{state}</div>; }
但并不是所有的业务场景都可以这样避免。。说了这么多不太理想的地方,还是再聊聊好的部分,hooks的出现,很多场景就都能够进行抽象和二次复用了:
const searchParam = useSearchParam("qs"); const data = useAsync(async () => { const res = await ajax(searchParam); return res; }); return <div>{data.loading ? "loading" : data.value}</div>;
如上述代码所示,其实这两个hook的实现都很简单,但是一个实现了指定url参数的获取,一个实现了对ajax过程的抽象,不再需要书写冗余url监听与组件loading状态,直接由hook提供,直接实现了view与viewmodel的解耦,用户只需要在自己的场景中实现loading组件即可。而正是这些细粒度的抽象,进一步切实的简化了开发者的重复工作。
这一边是React苦心孤诣的打造了函数式组件生态,而另一边,Vue也在最近迎来了一次大的更新,随着新版的vue将内部子模块拆得越来越细,特别是@vue/reactivity这个包的独立,提供给了笔者一个很有意思的视角:是否可以用拆分出的vue的数据绑定能力来操作react进行dom渲染呢?于是说干就干:
import React, { createContext, useContext, useEffect, useReducer, useRef } from "react"; import { effect, stop, reactive } from "@vue/reactivity";
// hook for update const useEffection = (...effectArgs) => { const effectionRef = useRef(); if (!effectionRef.current) { effectionRef.current = effect(...effectArgs); } const stopEffect = () => { stop(effectionRef.current); }; useEffect(() => stopEffect, []); return effectionRef.current; }; const useStore = selector => { const [, forceUpdate] = useReducer(s => s + 1, 0); const store = useContext(StoreContext); const effection = useEffection(() => selector(store), { scheduler: forceUpdate, lazy: true }); return effection(); };
// create reactive state const state = reactive({ count: 0 }); const increment = () => state.count++; const store = { state };
// react context const StoreContext = createContext(null); const Provider = StoreContext.Provider;
// function component function Count() { const countState = useStore(store => { const { state } = store; const { count } = state; return { count }; }); const { count } = countState; return ( <> <span style={{ marginRight: 10 }}>{count}</span> <span onClick={increment}>add</span> </> ); } export default function ComponentA() { return ( <> <Provider value={store}> <Count /> </Provider> </> ); }
使用reactivity来进行数据关联之后,配上点context,目前虽然绕了点弯路,还是实现了一个简单的计数器,以后随着vue的发展可能有更好的实现也说不定。
其实回过头来看,Hooks解决了Class时代抽象能力不足的同时,一些新特性的引入也给整个开发体验蒙上了一层阴影(诚然,本文本身切入点是有些问题的,不过笔者觉得,这依然是一个健康的生态需要考虑的问题)。当然,FB团队也并没有停止去思考更好的解决方案,(虽然并不是为了解决笔者所说的一些“缺陷”,而是着眼于整体方案的设计),Recoil就是他们给出的一个基于状态管理器的方案,基于Recoil,笔者又一次实现了一个Counter:
import React from "react"; import { RecoilRoot, atom, useRecoilState, useRecoilValue } from "recoil"; const countState = atom({ key: "countState", default: 0 }); function Counter() { const count = useRecoilValue(countState); return <>Count: {count}</>; } function Add() { const [count, setCount] = useRecoilState(countState); return <p onClick={() => setCount(count + 1)}>add</p>; } export default function App() { return ( <RecoilRoot> <Counter /> <Add /> </RecoilRoot> ); }
其实光看代码就能发现,和reactivity很像,并且省去了很多抹平react&vue差异的代码(...其实是他自集成了) ,同时,也省去了redux那纷繁复杂的大量模板代码,相比rematch也更为精简,还有人戏称是一个hooks时代的redux。不过在笔者看来,虽然Recoil解决了很多在redux时代颇为诟病的问题: 跨组件状态共享、状态互相依赖、异步状态等等,但就目前看来比较原子化的api,很难支撑起一个工程化的多人协作场景的,(此刻可能会有很多同学已经迫不及待的喊出那句“mobx它不香吗?”)需要有一些更多的规范和相关设施的建设,才能更有效的提高或者说将整个生态带往更好的未来。
但这又何尝不是这个时代给我们的希望呢?新的事物不断涌现,也许它还存在的许许多多的问题,但是,正是因为我们对它们一次又一次的审视、思考,才能引领我们走向更光明的未来。