setup vs 5 react hooks,助你避开"沟"中陷阱

setup vs 5 react hooks,助你避开"沟"中陷阱

image.png

序言

本文主题围绕concentsetup和react的五把钩子来展开,既然提到了setup就离不开composition api这个关键词,准确的说setup是由composition api带出来的概览,而composition api(组合api) 和 optional api(可选api) 两种组织代码的方式,相信大家在vue3各种相关的介绍文里已经了解到不少了,它们可以同时存在,并非强制你只能使用哪一种,但组合api两大优势的确让开发者们更倾向于使用它来替代可选api。

  • 函数为基础单位来打包可复用逻辑,并注入到任意组件,让视图和业务解耦更优雅
  • 让相同功能的业务更加紧密的放置到一起,不被割裂开,提高开发与维护体验

以上两点在react里均被hook优雅的解决了,那么相比hook,组合api还具有其他什么优势呢?相信已有小伙伴在尤大介绍组合api时已经知道,组合api是静态定义的,解决hook必需每次渲染都重新生成临时闭包函数性能问题,也没有了hook里闭包旧值陷阱,人工检测依赖等编码体验问题。

但是,react是all in js的编码方式,所以只要我们敢想、敢做,一切优秀的编程模型都可以吸纳进来,接下来我们用原生hook和concent的setup并通过实例和讲解,来彻底解决尤大提到的这个关于hook的痛点吧 ^_^

image.png

react hook

我们在此先设计一个传统的计数器,要求如下

  • 一个小数,一个大数
  • 有两组加、减按钮,分别对小数大数做操作,小数按钮加减1,大数按钮加减100
  • 计数器初次挂载时拉取欢迎问候语
  • 当小数达到100时,按钮变为红色,否则变为绿色
  • 当大数达到1000时,按钮变为紫色,否则变为绿色
  • 当大数达到10000时,上报大数的数字
  • 计算器卸载时,上报当前的数字

为了完成此需求,我们需要用到以下5把钩子

useState

过完需求,我们需要用到第一把钩子useState来做组件首次渲染的状态初始化

function Counter() {
  const [num, setNum] = useState(6);
  const [bigNum, setBigNum] = useState(120);
}

useCallback

如需使用缓存函数,则要用到第二把钩子useCallback,此处我们使用这把钩子来定义加减函数

  const addNum = useCallback(() => setNum(num + 1), [num]);
  const addNumBig = useCallback(() => setBigNum(bigNum + 100), [bigNum]);

useMemo

如需用到缓存的计算结果,则要用到第三把钩子useMemo,此处我们使用这把钩子来计算按钮颜色

 const numBtnColor = useMemo(() => {
    return num > 100 ? 'red' : 'green';
  }, [num]);
  const bigNumBtnColor = useMemo(() => {
    return bigNum > 1000 ? 'purple' : 'green';
  }, [bigNum]);

useEffect

处理函数的副作用则需用到第四把钩子useEffect,此处我们用来处理一下两个需求

  • 当大数达到10000时,上报大数的数字
  • 计算器卸载时,上报当前的数字
  useEffect(() => {
    if (bigNum > 10000) api.report('reach 10000')
  }, [bigNum]);
  useEffect(() => {
    return ()=>{
      api.reportStat(num, bigNum)
    }
  }, []);

嘿嘿,写到这里,react新手已经被带到陷阱里了,即闭包旧值陷阱,卸载那一刻提交的是最初的值,同时这里的清理函数useEffect写法在IDE是也会被警告,因为内部使用了num, bigNum变量,所以要求我们声明依赖。

useRef

可如果为了避免IDE警告,我们改为如下方式显然也不是我们表达的本意,我们只是想组件卸载时报告一下数字,而不是每一轮渲染都触发清理函数

  useEffect(() => {
    return ()=>{
      api.reportStat(num, bigNum)
    }
  }, [num, bigNum])

这个时候我们需要第5把钩子useRef,来帮忙我们固定依赖了,所以正确的写法是

  const ref = useRef(); // ref是一个固定的变量,每一轮渲染都指向同一个值
  ref.current = {num, bigNum}; // 帮我们记住最新的值
  useEffect(() => {
    return () => {
      const {num, bigNum} = ref.current;
      reportStat(num, bigNum);
    };
  }, [ref]);

image.png

完整的计数器

使完5把钩子,我们完整的组件如下

function Counter() {
  const [num, setNum] = useState(88);
  const [bigNum, setBigNum] = useState(120);
  const addNum = useCallback(() => setNum(num + 1), [num]);
  const addNumBig = useCallback(() => setBigNum(bigNum + 100), [bigNum]);
  const numBtnColor = useMemo(() => {
    return num > 100 ? "red" : "green";
  }, [num]);
  const bigNumBtnColor = useMemo(() => {
    return bigNum > 1000 ? "purple" : "green";
  }, [bigNum]);
  useEffect(() => {
    if (bigNum > 10000) report("reach 10000");
  }, [bigNum]);

  const ref = useRef();
  ref.current = {num, bigNum};
  useEffect(() => {
    return () => {
      const {num, bigNum} = ref.current;
      reportStat(num, bigNum);
    };
  }, [ref]);

  // render ui ...
}

当然我们可以基于hook可定制的特性,将这段代码单独抽象为一个钩子,这样的话只需将数据和方法导出,以便让多种ui表达的Counter组件可以复用,同时也做到ui与业务隔离,利于维护。

function useMyCounter(){
  // .... 略
  return { num, bigNum. addNum, addNumBig, numBtnColor, bigNumBtnColor}
}

concent setup

hook函数在每一轮渲染期间一定是需要全部重新执行一遍的,所以不可避免的在每一轮渲染期间都会产生大量的临时闭包函数,如果我们能省掉他们,的确能帮gc减轻一些回收压力的,现在我们来看看使用setup改造完毕后的Counter会是什么样子吧。

image.png

使用concent非常简单,只需要在根组件之前,先使用runapi启动即可,因此处我们没有模块定义,直接调用就可以了。

import { run } from 'concent';

run();// 先启动,在render
ReactDOM.render(<App />, rootEl)

接着我们将以上逻辑稍加改造,全部包裹到setup内部,setup函数内部的逻辑只会被执行一次,需要用到的由渲染上下文ctx提供的api有initStatecomputedeffectsetState,同时配合setState调用时还需要读取的状态state,也由ctx获得。

function setup(ctx) {// 渲染上下文
  const { initState, computed, effect, state, setState } = ctx;
  // setup仅在组件首次渲染之前执行一次,我们可在内部书写相关业务逻辑
}

initState

initState用于初始化状态,替代了useState,当我们的组件状态较大时依然可以不用考虑如何切分状态粒度。

initState({ num: 6, bigNum: 120 });

此处也支持函数式写法初始化状态

initState(()=>({ num: 6, bigNum: 120 }));

computed

computed用于定义计算函数,从参数列表里解构时就确定了计算的输入依赖,相比useMemo,更直接与优雅。

// 仅当num发生变化时,才触发此计算函数
computed('numBtnColor', ({ num }) => (num > 100 ? 'red' : 'green'));

此处我们需要定义两个计算函数,可以用计算对象描述体来配置计算函数,这样只需调用一次computed即可

computed({
  numBtnColor: ({ num }) => num > 100 ? 'red' : 'green',
  bigNumBtnColor: ({ bigNum }) => bigNum > 1000 ? 'purple' : 'green',
});

effect

effect用法useEffect是一模一样的,区别仅仅是依赖数组仅传入key名称即可,同时effect内部将函数组件和类组件的生命周期进行了统一封装,用户可以将业务不做任何修改便迁移到类组件身上

effect(() => {
  if (state.bigNum > 10000) api.report('reach 10000')
}, ['bigNum'])
effect(() => {
  // 这里可以书写首次渲染完毕时需要做的事情
  return () => {
  	// 卸载时触发的清理函数
    api.reportStat(state.num, state.bigNum)
  }
}, []);

setState

用于修改状态,我们在setup内部基于setState定义完方法后,然后返回即可,接着我们可以在任意使用此setup的组件里,通过ctx.settings拿到这些方法句柄便可调用

function setup(ctx) {// 渲染上下文
  const { state, setState } = ctx;
  return {// 导出方法
    addNum: () => setState({ num: state.num + 1 }),
    addNumBig: () => setState({ bigNum: state.bigNum + 100 }),
  }
}

完整的Setup Counter

基于上述几个api,我们最终的Counter的逻辑代码如下

function setup(ctx) {// 渲染上下文
  const { initState, computed, effect, state, setState } = ctx;
  // 初始化数据
  initState({ num: 6, bigNum: 120 });
  // 定义计算函数
  computed({
    // 参数列表解构时就确定了计算的输入依赖
    numBtnColor: ({ num }) => num > 100 ? 'red' : 'green',
    bigNumBtnColor: ({ bigNum }) => bigNum > 1000 ? 'purple' : 'green',
  });
  // 定义副作用
  effect(() => {
    if (state.bigNum > 10000) api.report('reach 10000')
  }, ['bigNum'])
  effect(() => {
    return () => {
      api.reportStat(state.num, state.bigNum)
    }
  }, []);

  return {// 导出方法
    addNum: () => setState({ num: state.num + 1 }),
    addNumBig: () => setState({ bigNum: state.bigNum + 100 }),
  }
}

image.png

定义完核心的业务逻辑,紧接着,我们可在任意函数组件内部使用useConcent装配我们定义好的setup来使用它了,useConcent会返回一个渲染上下文(和setup函数参数列表里指的是同一个对象引用,有时我们也称实例上下文),我们可按需获从ctx上取出目标数据和方法,针对此示例,我们可以导出

state(数据),settings(setup打包返回的法法),refComputed(实例的计算函数结果容器)这3个key来使用即可。

import { useConcent } from 'concent';

function NewCounter() {
  const { state, settings, refComputed } = useConcent(setup);
  // const { num, bigNum } = state;
  // const { addNum, addNumBig } = settings;
  // const { numBtnColor, bigNumBtnColor } = refComputed;
}

我们上面提到setup同样可以装配给类组件,使用register即可,需要注意的是装配后的类组件,可以从this.ctx上直接获取concent为其生成的渲染上下文,同时呢this.statethis.ctx.state是等效的,this.setStatethis.ctx.setState也是等效的,方便用户代码0改动即可接入concent使用。

import { register } from 'concent';

@register(setup)
class NewClsCounter extends Component{
  render(){
   const { state, settings, refComputed } = this.ctx;
  }
}

结语

对比原生hook,setup将业务逻辑固定在只会被执行一次的函数内部,提供了更友好的api,且同时完美兼容类组件与函数组件,让用户可以逃离hook的使用规则烦恼(想想看 useEffect 配合 useRef,是不是都有不小的认知成本?),而不是将这些约束学习障碍转嫁给用户, 同时对gc也更加友好了,相信大家都已认了hookreact一个重要发明,但是其实它不是针对用户的,而是针对框架的,用户其实是不需要了解那些烧脑的细节与规则的,而对于concent用户来说,其实只需一个钩子开启一个传送门,即可在另一个空间内部实现所有业务逻辑,而且这些逻辑同样可以复用到类组件上。

image.png

眼过百遍不如手过一遍,以下是两种写法的链接,尝试尝试一定有所心得

上诉两个hook Counter如果想做状态共享,我们需要改造代码接入redux或者自建Context,但是在concent的开发模式下,setup无需任何改造,仅仅只需要提前声明一个模块,然后注册组件内属于该模块即可,这种丝滑般的迁移过程可以让用户灵活应对各种复杂场景。

import { run } from 'concent';

run({
  counter:{
    state: { num:88, bigNum: 120 },
  },
  //reducer: {...}, // 如操作数据流程复杂,可再将业务提升到此处
})

// 对于函数组件
useConcent({setup});
//  ---> 改为
useConcent({setup, module:'counter'})

// 对于函数组件
@register({setup});
//  ---> 改为
@register({setup, module:'counter'});

One more thing

如果对concent搭建admin站点感兴趣,我们也提供一个示例站点tntweb-admin供你参考,得益于wp2vite支持,实现了本地既可vite启动也可webpack启动的双引擎驱动能力,你只需3步即可:

git clone git@github.com:tnfe/tntweb-admin.git
npm i
npm run vite

当然了如果希望用webpack启动,使用npm run start接可以了,不过推荐上线还是用webpack构建来上线,即npm run build

除了双擎驱动,tntweb-admin还内置了超多特性,如实时的主题换肤页签27种动态排版功能,欢迎关注,同时他本身也是内置了微前端架构模式的站点,这一块还在开发中,后续我们的更多模板页面发布完毕,以及微前端部署模式文档ready后,会第一时间开放给广大开发者共享。

团队

TNTWeb - 腾讯新闻前端团队,TNTWeb致力于行业前沿技术探索和团队成员个人能力提升。为前端开发人员整理出了小程序以及web前端技术领域的最新优质内容,每周更新✨,欢迎star,github地址:https://github.com/tnfe/TNT-Weekly

image.png

相关文章

一、前言 在组件方面react和Vue一样的,核心思想玩的就是组件...
前言: 前段时间学习完react后,刚好就接到公司一个react项目...
前言: 最近收到组长通知我们项目组后面新开的项目准备统一技...
react 中的高阶组件主要是对于 hooks 之前的类组件来说的,如...
我们上一节了解了组件的更新机制,但是只是停留在表层上,例...
我们上一节了解了 react 的虚拟 dom 的格式,如何把虚拟 dom...