这套方案究竟是如何设计的?他以actions为暴露出来的接口,实现了对业务逻辑的封装,通过context,实现业务逻辑的全局复用。并且他通过useEffect的监听,实现了状态驱动的、函数参与进来的状态流转(函数包括请求,也包括一些赋值)。
React中的两种的模式
React中有两种力量。以唤起一个Modal组件为例
Modal.confirm() // 典型的命令式
<Modal visibility={isHidden} /> //典型的状态式
显示一个弹框组件,我们可以通过设置visible,修改状态来获得。也可以通过Modal.confirm方法,命令式的生成。 我个人喜欢使用state来控制modal,而不是命令式。
这个问题在发起请求的时候,也是类似的。 在class组件中,我们习惯在componentDidMount事件钩子里面发起请求。 我不喜欢这种方式。有几个原因:
- React是状态驱动的。而钩子,本身就不算是一个纯粹的状态,钩子里面调用一个请求,也是命令形式的。
- 对于抽象不够灵活抽象。一个生命周期并不轻松。我们虽然可以通过Hoc来做,但并不轻松。
那么到了Hook的时代,当我们可以摆脱生命周期来描述函数组件的时候,该如何去发起请求呢?那就是useEffect——将函数作为组件渲染结果的一部分而被调用的能力。
useEffect
Hooks API Reference – Reactreactjs.org普通的useEffect和执行一段函数并没有区别
useEffect(func()) // 等同
func() // 等同
在React函数组件中,每当完成渲染之后,都会重新执行这个函数。他是一个全新的函数,因此内部的所有变量,也都是重新生成的。 在没有使用dep的情况下,这往往会造成死循环。因此我们常见如一个空数组作为dep。
useEffect(
() => {
ajax1();
}, [])
这样这段函数就在最开始的时候执行一次。
但是这种写法仍然有问题首先这本质上还是在用命令式:请第一次执行这个函数的时候,发起ajax1请求。这样换汤不换药。其次。由于hook里面,setState是没有回调函数的,有时候根本就写不出来。
例如,进入页面发起请求1,设置结果1后,用结果1作为参数请求B,B得到后用B请求C。我们习惯的异步串行的思路写不出来,因为没有回调。
useEffect(() => {
ajax1().then((res1) => {
setData1(res1)
})
}, [])
useEffect(() => {
ajax2(data1).then((res2) => {
setData2(res2)
})
}, [])
因此我们需要深入理解useEffect,认识他的dep参数的作用。
depdence
直观来看,dep达成了某种响应式:当dep变化的时候,请执行函数(第一次也当然会执行)
// useEffect = function + dependce
useEffect = function (func) {
// 当依赖变化,才执行
if (dep change) {
func()
}
}
因此我们可以使用useEffect,监听当特定的dep变化的时候,执行函数,来发起请求。 因此我们应该改写成
useEffect(() => {
ajax1();
}, [ajax1])
dep里面的东西是什么? 他有多种角度的解释。
最简单的,他是依赖。当他上面的变量变化的会后,函数就重新执行。关键,我怎么知道哪些需要指定哪些作为依赖呢?
一个函数,一个有副作用的函数,它上面所有的变量,也就是每次执行可能导致他产生不一样的结果的变量,都是他的依赖。那么,所有的输入都是依赖,所有用到的变量也都是依赖。所有props,state还是变量。(除了const还有其他的不变量、例如useRef、dispatch都是依赖)
这块我举个拍电影的例子加强理解。如果说React函数式组件每次执行都 像生成电影中的一帧。那么不仅有主角们演的戏,还得有背景。Render就是导演负责主角们怎么演戏,而useEffect就是背景。每个useEffect包装的function就都是用来生成背景的脚本,里面有很多部分:
- 有墙上挂的闹钟(timer)
- 有不变的道具(const)
- 有随着时间变化的太阳光(state)
- 还有从天而降的雨(props)
- 还有一些奇奇怪怪的群演(入参,let变量)
而useEffect这位背景导演,是一个很严谨的人:每当render导演演算过一帧的结果之后,他会检查上面这些因素中是否有任何改变了,如果有的话,他会以场景变化为理由:将这个function背景脚本也重新执行一遍(并且会在执行完毕之后,执行上一次的“清除背景”的命令)。useEffect乐此不疲。电影一帧帧的跑,主角被state或者props一帧帧的指挥,而忠实的背景在主角每次运动后,都会仔细的判断背景上的元素是否有差异,是否需要重新执行。这就是useEffect的价值。useEffect很难凭借肉眼去查看背景结果的变化来判断是否产生差异,但是它学会通过间接比较这些“背景元素”,也就是dep数组来知晓,以此为标准决定何时应该重新执行背景。
在React这部拍电影的工具看来,如果仅仅是状态驱动主角,而通过生命周期来操纵背后的事件的话,不够过瘾,他仍然有命令式的痕迹。这次,他借助useEffect延展了状态的职能范围,让函数的执行也在状态的统帅之下,达成了完美的统一。世界上从此只有状态和状态带来的结果(渲染和背景都是结果的一部分)。 useEffect的精髓是,让状态,而非命令或者生命周期去驱动事件。回顾一下useEffect的思路,我们发现他和state一脉相传。
我不知道dom是如何渲染出来的。但我知道state是如何变化的。因此我也就知道,当state变化的时候,我就去执行render。
我不知道请求是如何发起的。但是我知道这个ajax需要哪些state作为依赖。因此我也就知道,当依赖变化的时候,我就去执行请求。
//对于state我们有
view = render(state)
//对于函数,我们有
callBack = effect(state)
//综合起来可得
perRenderResult = render(state) + effect(state)了
从我个人的理解上,useEffect让函数执行成为了React引以为豪的单项数据流的一部分。现在一个特定的状态,不仅对应了一个唯一的视图,也对应了唯一的一组函数执行结果。这种使用函数的玩法我叫他响应式函数 在这个角度上看:请求,就是渲染过后的额外作用函数。
请求如何复用
上面得出了,可以通过useEffect来将请求变为由状态驱动的渲染结果的延伸,从而响应式的进行请求。那接下来,我们如何进行复用呢?例如在不同页面调用同一个请求?
我们实际上复用的不是一个请求,复用一个请求,并不难,我们只需要一个通用的server层就可以了。
我们也不是要复用一个发起请求的动作,复用一个effect,我们只需要一个useCustomerHook就可以了。
我们要复用的是一整套的业务逻辑。是一次请求,还有后续的一系列异步的步骤,操作。 因此就有了actions的概念。(actions的概念借鉴于Flux)
actions
action是业务逻辑的代言人,他就像一条指令一样,他背后抽象了大量的细节。例如,场景请开始下雪、添加购物车、购买商品、刷新列表、这就是一个个action。而他们背后抽象的是:先查询数据,再更新数据,再进行数据上报,也就是一连串的异步细节。我选择的复用的单元就是actions。 每一个actions它本质上是请求的业务包装体,他被注入以下内容:
- 数据组织(get state)
- 数据赋值(set dispatch)
- 数据拉取(server ajax)
// 将state和dispatch注入
export function useGetActions (
state: IStoreTestNameState,
dispatch: (action: IReducerAction) => void
): IStoreTestNameActions {
// 通过useCallBack包装:获得: 依赖于状态变量的,响应式的actions.
const getTestAjaxValue = useCallback(async function() {
const res = await storeTestNameServer.getTestAjaxResult();
dispatch({
type: storeTestNameReducerTypes.setTestValue,
value: res
});
}, [dispatch])
// 将actions返回
return {
getTestAjaxValue
};
}
state和dispatch,就是我们将reducer中的东西传递过来了。和我们在redux-thunk的回调中拿到的东西是相同的。你在一个actions里面,具有完全的能力掌握异步流程还有数据。因为你有dispatch,你可以异步的通过变更状态来实现状态驱动。
useReducer
// reducer action types
export const storeTestNameReducerTypes = {
setTestValue: "setTestValue"
};
// reducer
function reducer(state: IStoreTestNameState, action: IReducerAction) {
const { type, value } = action;
let newState = { ...state };
switch (type) {
case storeTestNameReducerTypes.setTestValue: {
newState = {
...newState,
testValue: value
};
break;
}
default:
newState = { ...newState };
}
return newState;
}
const [state, dispatch] = useReducer(reducer, initState);
action的数据是useReducer来统治的——我们在actions中的state变更是通过它来实现的。使用useReducer有三个好处:
- 他显然比useState更适合管理复杂的数据。
- 他和context配合更融洽。他是context的数据容器。
- 也是最关键的一点:dispatch具有穿越时空的能力,不被束缚在一次结果的中。
整个store的状态,都由actions发起,再借助useReducer来驱动的。useReducer驱动的优点在于,他是相对永恒的。无论你在任何位置,为何时刻访问一个函数式组件,他的dispatch有且只有一个,我称这种特性叫做跨越时空的能力。useReducer,useRef,useContext都有类似的特点。看起来他们都像单例一样。 相比而言,而useEffect中的函数可不一样,每次执行后,里面的函数都是全新的,他们捕获当下的闭包,因此整个函数体内的变量也是全新的。 但是dispatch,只有一个。这种单例性,让我们在任何地方的dispatch,都有相同的结果。这是一种强有力而必要的保障。
结合上面的例子,实际上每一帧电影场景,上面的所有主角动作(state,props),所有背景的效果,都是定格的。直到状态发生转变,重新来过,再渲染一帧。我称这种现象叫时空定格。也就是,一次渲染的结果是不变的,setState只会带来新的一帧,而不会影响到过去的渲染。一种状态组合,得到的只有唯一确定的结果。而dispatch和那些能够穿越时空的元素,就像是摄像机一样,不被电影的每一帧所束缚,他永远静静的在那里,自由穿行。在渲染的任何一帧,拿到的dispatch,都是亘古不变的。
然而reducer对应的state并不具有这种特点。那么当state变化的时候,如何处理呢?我使用useCallBack对他们进行响应式的包装。
useCallBack
useCallBack和useEffect有点类似,他们从语义上来说都是
useEffect:当[dep]变更之后,请重新执行
useCallBack:当[dep]变更之后,请重新计算得到最新的函数。
因此当一个actions,根据内部使用到的state,借助useCallBack进行响应式变更之后就可以保证了这个action封装体,永远都是最新的.因为每当actions内部依赖的state,也就是他的dep发成改变的时候,他会重新计算出最新的函数。(useMemo和useCallBack是一个东西)
为什么要费这么大劲,保证函数是最新的呢?有两个原因: 1. 因为我们内部流程依赖于这些最新的数值,他们不能有误。用陈旧的数值进行请求或者计算,显然得不到正确的值。 2. 因为当一个actions变化的时候,我们往往需要去重新发起请求(通过useEffect)例如,我们拉分页。那么就有
// 几个变量相互依赖
pageNumber ->
getListByPn(pageNumber) ->
useEffect(actions.getListByPn(), [actions.getListByPn])
我们的请求也会被pn这个状态所驱动更新。因此响应式的函数更新带给我们响应式的调用,在必要的时候进行刷新ajax。这是状态驱动请求的核心。重新梳理一下逻辑:
- 我们的请求内容,借助reducer进行数据操作的解耦。
- state + dispatch + server ajax 组成了actions。
- 这些要素都被actions借助useCallBack进行响应式的包装,形成会响应式更新actions。
wrappedActions = useCallBack(actions)
。每一个action都是上面这些元素的组合封装。 - 而最外层,通过useEffect监听对应值的变化,执行对应的更新操作。
useEffect(wrappedActions, [wrappedActions])
实际上,dispatch是从Redux的思想中来的。dispatch代表了纯函数reducer的触发器,他天生就是纯粹的,全局的,单例的。他是整个app状态的驱动器。 actions是从flux借鉴来的。他是变化的发起者,由actions负责来协调数据的请求还有指挥dispatch的调用。他是业务的抽象接口。
上代码
这是我在项目中100行实现一个业务层的store的模板,他的内部就是由useReducer,actions,useCallBack来进行的业务抽象封装。
import React, {createContext, useReducer, useEffect, useCallback} from "react";
// 引入请求层
import {storeTestNameServer} from "../server";
export const StoreTestNameContext = createContext({});
export interface IReducerAction {
type: string;
value?: any;
}
// store name
export const StoreTestName = "StoreTestName";
// store state
export interface IStoreTestNameState {
testValue: number;
}
// store context value
export interface IStoreTestNameContext extends IStoreTestNameActions {
storeTestNameContextValue: IStoreTestNameState;
storeTestNameContextDispatch: (action: IReducerAction) => void;
}
// store provider
export function StoreTestNameContextProvider(props: any) {
const initState: IStoreTestNameState = {
testValue: 101
};
// 使用useReducer来管理数据
const [state, dispatch] = useReducer(reducer, initState);
// 通过useHook的形式。将state dispatch注入到actions之中。获取actions
const actions: IStoreTestNameActions = useStoreTestNameGetActions(
state,
dispatch
);
// global useEffect
const { getTestAjaxValue } = actions;
useEffect(() => {
getTestAjaxValue();
}, [getTestAjaxValue]);
// TODO Step 3: 将数据传入到context中,暴露给子组件
const contextValue: IStoreTestNameContext = {
// contextValue = actions + stateValue + dispatch
...actions,
storeTestNameContextValue: state,
storeTestNameContextDispatch: dispatch
};
// 传入到context中,让组件引用
return <StoreTestNameContext.Provider value={contextValue} {...props} />;
// TODO Step 4: 在子组件完成引用
}
// actions type
// TODO Step 1: 定义好新增action的结构
export interface IStoreTestNameActions {
getTestAjaxValue: () => void;
}
// useGetActions
export function useStoreTestNameGetActions (
state: IStoreTestNameState,
dispatch: (action: IReducerAction) => void
): IStoreTestNameActions {
// 通过useCallBack包装:获得: 依赖于状态变量的,响应式的actions。
// TODO Step 2: 使用useCallBack包装业务逻辑细节
const getTestAjaxValue = useCallback(async function() {
const res = await storeTestNameServer.getTestAjaxResult();
dispatch({
type: storeTestNameReducerTypes.setTestValue,
value: res
});
}, [dispatch])
// 将actions返回
return {
getTestAjaxValue
};
}
// reducer action types
export const storeTestNameReducerTypes = {
setTestValue: "setTestValue"
};
// reducer
function reducer(state: IStoreTestNameState, action: IReducerAction) {
const { type, value } = action;
let newState = { ...state };
switch (type) {
case storeTestNameReducerTypes.setTestValue: {
newState = {
...newState,
testValue: value
};
break;
}
default:
newState = { ...newState };
}
return newState;
}
demo地址
其实说到底,这套方案究竟解决了什么?
他以actions为暴露出来的接口,实现了对业务逻辑的封装,通过context,实现业务逻辑的全局复用。并且他通过useEffect的监听,实现了状态驱动的、函数参与进来的状态流转(函数包括请求,也包括一些赋值)。
他相比传统的基于生命周期的React请求数据的优势在于:响应式更多抽象了一层,让我们关注于描述状态和行为之间的关系。我们不直接命令式的描述过程。这在书写上会更加简练,清晰,尤其在复杂错综的关系代码下,异步并不好写。虽然编写难度提升了,但是代码复杂度和维护性都有很大加分。这实际上是React优势的延伸,从响应式的UI扩大为响应式的执行函数逻辑。
在实践这套方案的过程中,通过长期和hook打交道,我总结了一些常见的经验和技巧。
1、诚实原则
如果我们按照useCallBack和useEffect的依赖原则去书写,我们就不会遇到解决不了的问题。 相反,如果我们按照心意,按照需求去写。就会造成大量数据过期,请求没有及时响应数据的变化,等问题。
这个概念,和vue里面响应式计算的computed有点像。当值变化的时候,解也需要变化。那么什么时候执行computed的,显然是通过依赖来判断,如果我们打破了这种自然的依赖,app的状态就会陷入混乱。
这个原则背后的道理就是,如果你的函数流程里面使用了一个变量。那么他就一定需要依赖这个变量来重新执行。这是需要遵守的最核心的一块。
为了实现这个原则,感谢CRA集成了了eslint-plugin-react-hooks
,我们只需要按照规则去自动补全就ok了。
2、最小封闭原则
如何确定useEffect发起的位置?用这套方案,有三个地方可以。
- 页面级别(页面)
- 业务模块(业务模块)
- context store级别(global)
越往上,scope越大。其实这三者没有区别,只不过是限定条件的差异。而这里的限定条件,就是路由划分。
- 页面:localhost:3000/buy/entry
- 业务模块: localhost:3000/buy/
- store context: localhost:3000/
也就是说,如果我们用if来额外添加关于url的判断条件,就算全部写在作为全局的store中,也能有和写在页面上类似的效果。只不过依赖性的请求,其实要比命令性的请求更难控制。所以要更小心处理。
我们用类似于处理state的策略来处理useEffect这个状态。如果一个如果effect仅仅在页面发生,那么就请写在页面上。直到他需要通过类似于状态提升来实现更广泛的共享。如果你最终写在了全局上,务必注意添加类似于路由判断等额外的condition,不然就会造成在任何页面都会触发的悲剧。
3、useAction可拆分原则 具体问题;依赖关系很复杂怎么办?(这是可拆分原则)
其实,任何函数本身都是可拆分的。我们来思考纯函数这个概念。
带有状态的函数,肯定不纯。带有请求的组件,肯定也不纯。
那么所谓纯化,无非就是将不纯的状态扔给其他的组件来维护,例如父组件。不纯的组件,本质上就是复合了状态的组件,它可以带来业务流程的抽象。而纯函数组件,带来了纯粹流程的抽象。这两者应该根据具体的业务场景来使用。并没有绝对的对错。
例如一个组件,他自身有状态,妨碍了他被更广泛的复用。那么这时候,剥离这个状态,显然非常划算。
同样是这个组件,所有引用他的地方,都希望他自己维护这个状态,他们的业务需求都完全想用,那么他当然应该变成不纯的组件,自带状态。
所以,这些道理都是共通的,也适用于action函数的设计:每一个action的具体实现,完全取决于业务的复用情况和复杂度来决定颗粒度大小。
// 任何action请求,都可以拆分成纯函数 + 状态的形式。
action = pureActionFunction + state
我们只需要根据业务的具体情况,为了满足对应的复用,来选择包装state,或者抽离为纯函数。
结束
以上就是我对使用hook,基于useContext这个状态管理数据流的一些思考。
这套方案,或者说分层方式,在做SSR的时候,也有非常棒的效果。下次我们讨论下在Node端如何进行SSR代码的分离处理,还有相应的数据回补操作,如何针对这块的代码进行同构渲染。
以上只是我在我的业务环境下的思考和总结,难免有局限性。欢迎小伙伴提出建议,共同讨论!
Dan大佬的文章:https://overreacted.io/a-complete-guide-to-useeffect/
上一篇文章地址:React Hooks 数据管理方案(Part1)