数据弹框组件_React Hooks 数据管理方案(Part2)

这套方案究竟是如何设计的?他以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 – React​reactjs.org
26056febd9e8b4b0aaaaec95a13433bc.png

普通的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它本质上是请求的业务包装体,他被注入以下内容:

  1. 数据组织(get state)
  2. 数据赋值(set dispatch)
  3. 数据拉取(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有三个好处:

  1. 他显然比useState更适合管理复杂的数据。
  2. 他和context配合更融洽。他是context的数据容器。
  3. 也是最关键的一点: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。这是状态驱动请求的核心。重新梳理一下逻辑:

  1. 我们的请求内容,借助reducer进行数据操作的解耦。
  2. state + dispatch + server ajax 组成了actions。
  3. 这些要素都被actions借助useCallBack进行响应式的包装,形成会响应式更新actions。wrappedActions = useCallBack(actions)。每一个action都是上面这些元素的组合封装。
  4. 而最外层,通过useEffect监听对应值的变化,执行对应的更新操作。useEffect(wrappedActions, [wrappedActions])

a1d46a3feb77d914e1ed3bf3caf332d8.png
最后就抽象成这么一个直白的action。他封闭了所有的内部复杂度。

c2723f4af8c7c0ec5ba9f5d47648b6e3.png
所有的细节和依赖都被隐藏在背后

实际上,dispatch是从Redux的思想中来的。dispatch代表了纯函数reducer的触发器,他天生就是纯粹的,全局的,单例的。他是整个app状态的驱动器。 actions是从flux借鉴来的。他是变化的发起者,由actions负责来协调数据的请求还有指挥dispatch的调用。他是业务的抽象接口。

59321baff8adb01de544346aa98f168e.png
Actions

上代码

这是我在项目中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发起的位置?用这套方案,有三个地方可以。

  1. 页面级别(页面)
  2. 业务模块(业务模块)
  3. context store级别(global)

越往上,scope越大。其实这三者没有区别,只不过是限定条件的差异。而这里的限定条件,就是路由划分。

  1. 页面:localhost:3000/buy/entry
  2. 业务模块: localhost:3000/buy/
  3. 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)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
React是一种用于构建用户界面的JavaScript库,它将应用程序拆分成小而可重用的组件。而Hooks是React 16.8版本新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他 React 特性。 在React数据共享可以通过Context来实现。而封装Hooks组件可以通过自定义Hooks来实现。 下面是一个简单的例子,展示如何封装Hooks组件实现数据共享: ```javascript import React, { createContext, useContext, useState } from 'react'; // 创建一个Context对象 const CounterContext = createContext(); // 自定义Hook,提供计数器状态和操作函数 function useCounter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); }; const decrement = () => { setCount(count - 1); }; return [count, increment, decrement]; } // CounterProvider组件,提供计数器状态和操作函数 function CounterProvider(props) { const counter = useCounter(); return ( <CounterContext.Provider value={counter}> {props.children} </CounterContext.Provider> ); } // Counter组件,使用计数器状态和操作函数 function Counter() { const [count, increment, decrement] = useContext(CounterContext); return ( <div> <p>Count: {count}</p> <button onClick={increment}>Increment</button> <button onClick={decrement}>Decrement</button> </div> ); } // 在App组件使用CounterProvider和Counter组件 function App() { return ( <CounterProvider> <Counter /> </CounterProvider> ); } export default App; ``` 上面的例子,我们首先定义了一个自定义Hook useCounter,用于提供计数器状态和操作函数。然后,我们创建了一个CounterContext对象,并使用CounterProvider组件来提供计数器状态和操作函数。最后,我们使用useContext Hook在Counter组件获取计数器状态和操作函数,并渲染出来。 相关问题: 1. React的Context是什么? 2. Hooks是什么?有哪些常用的Hooks? 3. 自定义Hooks有什么好处? 4. 在React如何实现数据共享?

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值