React编程模型中组合的魅力 · Custom Hooks

2,412 阅读9分钟

写在前面

我们知道React专注于构建UI界面多年,文档上对自己的定位也十分清晰,A JavaScript library for building user interfaces, 所以当你越深入React你可能越会发现,React的编程模型更趋向于组合个人信息展示举个UI例子,贴图,比如我们要实现个人信息展示,把它分成头像、昵称(支持单行超出省略)、个性签名三个部分,如果我们之前有沉淀头像组件和文本展示组件,通过简单的组合,个人信息功能就完成了,在React的世界里,万物皆可组件化,通过组合,可以大大省去了CV的烦恼。既然UI可以通过组合来复用,那么逻辑是否可以通过组合来复用呢?

从设计谈逻辑复用

为了逻辑的复用,React可以在class组件使用HOC或者Render Props,这里就从另一个角度谈一谈这两种方式。由于是基于class组件,而在class组件写逻辑大多数时候都是基于生命周期去写,说白了就是基于时间,你只能在特定的时候做特定的事情,在这个基础上实现逻辑复用,很大程度上会写很多的看似冗余但是不可避免的代码,如果写一个withXXX的HOC,我们可能需要手动地拷贝静态属性,为了调试可能需要手动指定displayName,如果想要实现withRouter(connect(commentSelector)(WrappedComponent)),通常需要借助一个类似redux的compose或者装饰器来组装,但是本质上这些HOC还是基于嵌套,如果嵌套过深,对于调试来说真是噩梦~;更重要的是,这和React主张的编程模型有点不同了,所以这就是为什么React要花费这么大力气推出Function Component + Hooks的提案,同时我相信Hooks这么设计的很大原因就是为了贴合编程模型——组合,下面就从设计谈一谈逻辑复用吧!

之前就说过,Hooks最大的亮点其实是Custom Hooks,因为更贴合React的编程模型。上面提到的组件可以自由地组合,因为组件间并不会相互影响,因此要实现这种逻辑组合首要关键点就是互相不影响。也就是说我们再用React提供的useState、useEffect...等钩子的时候,可以任意的组合使用,完全不需要担心他们会相互影响,这就很大程度上决定了为什么Hooks要设计成顺序调用。记得之前看过一个被官方否决的Hooks的提案,具体提案忘记了,大概的意思就是自定义一个hooks实现类似SCH的功能,名字就叫useShouldComponentUpdate,下面用伪代码解释一下用法和问题

function useShouldComponentUpdate(fn, depVal) {
	const prevDepVal = uesRef(depVal)
	useEffect(()=>{
    	if(fn(prevDepVal.current)) {
        	prevDepVal.current = depVal
        } else {
        	// 伪代码, 跳过更新
           skipUpdateComponent()
        }
    }, [depVal])
}
// 自定义hooks,获取用户名
const useFetchUsername = name => {
	...
  	useShouldComponentUpdate(prevName => prevName !== name, name)
    ...
    return name
}
// 自定义hooks,获取用户年龄
const useFetchAge = age => {
	...
  	useShouldComponentUpdate(prevAge => prevAge !== age, age)
    ...
    return age
}
// 组件
function UserInfo({name, age}) {
	...
    const name = useFetchUsername(name)
    const age = useFetchAge(age)
    ...
    return <>{name}, {age}</>
}

这里的问题在于useFetchUsername和useFetchAge都可以跳过更新,这样会可能会导致其他的hooks或者逻辑被阻塞,例如useFetchUsername跳过了更新,useFetchAge的状态就得不到更新,所以useShouldComponentUpdate这样的提案就不合理,因为会导致hooks间互相影响,违反了组合原则。

说完了顺序调用,就来说说调试。我们知道在React中,有UI=f(data)的公式,我们可以直观地发现,如果你的UI不符合你的预期,是可以通过data直观反映,也就是所谓的数据驱动视图。所以Hooks的设计不能破坏这个根本原则,另外为了逻辑复用Hooks必须支持组合,因此Function Component就变成了天然的载体,在FC的每一次渲染中,Hooks都有自己的props和state,也就是每一次的渲染在Hooks中变量都有一个确定的值,这就让他具备了时间回溯的能力,不管你渲染n次,你第一次的值永远是那个;而且在你组合使用Hooks的时候,如果出现了问题,你可以通过具体出错的值轻松地定位到对应的Hooks,而不用担心会有什么动态依赖或者一层一层地往上找~对于调试的重要性,用的越复杂体会越深。

最后简单的补充一下,由于Hooks的设计时刻围绕着闭包,确实容易产生一些理解的成本,但是只要改变一下心智模型,Hooks还是很香的。这里顺便安利一下偶像Dan Abramov的文章 ——Why Do React Hooks Rely on Call Order?,期望你读完之后会有所顿悟~

悟了

所谓复用

想一下在React有一段代码,你经常会用到,你每次要用到都是复制之前的,再改一下就完事了!这是不是一点都不“程序员”所以我们会把一些方法封装成函数,如果是哪里都能用的,就放到了全局的util工具函数里面(如format);如果是组件能用的,我们考虑封装成HOC(如withTitle);现在在React中有一个更好的办法,就是custom hooks,因为可以基于组合,所以实现逻辑复用就nice!下面通过两个场景讨论一下复用

场景1:有商城和我的道具两个页面,数据结构一致,现在需要根据加载状态显示loading组件;同时需要对返回的数据结构做一层处理

① http: axios的实例,加了统一配置和拦截器,返回了response?.data
② formatRes: 对返回的数据结构做一层处理
// 商城页面
const [loading, setLoading] = useState(false);
const [list, setList] = useState([]);
const getMallList = () => http.get('xxx/api/getMallList')
useEffect(() => {
    ;(async () => {
      setLoading(true)
      const res = await getMallList().catch(() => {
        setLoading(false)
      })
      if (res?.code === 0) {
       setList(formatRes(res.data))
      }
    })()
  }, [])

// 道具页面
const [loading, setLoading] = useState(false);
const [list, setList] = useState([]);
const getPropsList = () => http.get('xxx/api/getPropsList')
useEffect(() => {
    ;(async () => {
      setLoading(true)
      const res = await getPropsList().catch(() => {
        setLoading(false)
      })
      if (res?.code === 0) {
       setList(formatRes(res.data))
      }
    })()
  }, [])

我们发现,这两个页面基本做了同样的事情,如果我们能把loading,list和formatRes这些逻辑抽离出来,只需要传getMallList或者getPropsList这些请求,然后返回包装过后的请求函数,loading和format后的结果,听起来还不错另外,我们再大胆一点, 把页面挂载后请求的这次副作用Effect也顺便做了,就更不错了动手写个hooks,姑且叫它useHttp

function useHttp(fetchFn, options = {}) {
  const {
    initialData,
    manual = false,
    formatRes,
  } = options;
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState(initialData);
  const [error, setError] = useState();
  const fetch = usePersistFn((...args) => {
    console.log("arg", args);
    setLoading(true);
    const result = {};
    fetchFn()
      .then(
        (res) =>
          (result.res = typeof formatRes === "function" ? formatRes(res) : res)
      )
      .catch((err) => (result.err = err))
      .finally(() => {
        setLoading(false);
        setData(result.res);
        setError(result.err);
      });
  });

  useEffect(() => {
    if (manual) return;
    fetch();
  }, [manual, fetch]);

  return { fetch, loading, data, error };
}

// 商城页面
const getMallList = () => http.get('xxx/api/getMallList')
const { loading, data } = useHttp(getMallList, {
  formatRes,
})

// 道具页面
const getPropsList = () => http.get('xxx/api/getPropsList')
const { loading, data } = useHttp(getPropsList, {
  formatRes,
})

正准备美滋滋下班的时候,产品突然过来说,这个loading能不能延迟加载,数据返回很快页面会闪一下啊,这!还挺合理的.... 回看代码我又发现,一旦请求有error,data就变为undefined了,啊,这 然后一波谷歌之旅之后,灵感倍增,既然是组合hooks,完全可以把loading独立;通过传入回调让外层拥有处理请求结果的能力。

// 新增了useLoading Hooks;新增onSuccess和onError回调, 下面提供改动后的useHttp代码
function useLoading(initialVal, delayTime) {
  const timer = useRef(null);
  const [loading, setLoading] = useState(initialVal);
  const perSetLoading = usePersistFn((isLoading) => {
    timer.current && clearTimeout(timer.current);
    if (delayTime && isLoading) {
      timer.current = setTimeout(() => {
        setLoading(isLoading);
      }, delayTime);
    } else {
      setLoading(isLoading);
    }
  });
  useEffect(
    () => () => {
      timer.current && clearTimeout(timer.current);
    },
    [timer]
  );
  return [loading, perSetLoading];
}

function useHttp(fetchFn, options = {}) {
  const {
  	// ...
    onSuccess,
    onError
  } = options;
  // ...
  const [loading, setLoading] = useLoading(false, 1000);
  const fetch = usePersistFn((...args) => {
    fetchFn()
      .finally(() => {
        typeof onSuccess === "function" && onSuccess(result.res);
        typeof onError === "function" && onError(result.err);
      });
  });
}

通过改造后,useHttp基本能满足大多数的场景了,对于上面的场景,如果只是请求一次列表,完全可以使用返回的data配合formatRes解决问题,如果是需要分页等操作,可以由外部通过onXX回调自己接管每次请求返回的数据,当然机智的你可能想到了这里完全可以再写一个hooks,比如叫作usePagination。

不愧是我

场景2:还是商城页面,有个tab是进场动效,都是svga文件

我们发现,有多处地方都要用到这个,考虑抽成组件,然后发现可能只需要复用逻辑,不需要UI,这时候就考虑抽离成hooks了,叫useSVGA

SVGA: 这里用到的是npm包--svgaplayerweb,现在推出了svga.lite,性能更加
② usePersistFn: 持久化一个函数(引用不会变)的hooks,和umi hooks的usePersistFn一样

export default function useSVGA(selector, src) {
  const svgaPlayer = useRef()
  const svgaParser = useRef()

  useEffect(() => {
    svgaPlayer.current = new SVGA.Player(selector)
    svgaParser.current = new SVGA.Parser() // 如果你需要支持 IE6+,那么必须把同样的选择器传给 Parser。
    return () => {
      svgaPlayer.current = null
      svgaParser.current = null
    }
  }, [selector])

  const play = usePersistFn(() => {
    svgaParser.current?.load(src, (videoItem) => {
      svgaPlayer.current?.setVideoItem(videoItem)
      svgaPlayer.current?.startAnimation()
    })
  })

  const stop = usePersistFn(() => {
    svgaPlayer.current?.stopAnimation()
  })

  return {play, stop}
}

// 如果需要一个页面展示多个svga,可以考虑不传id,让id自增
let idCount = 0
function SVGAPlayer({
  id = `__svgaContainer_${idCount++}`,
  src,
  style = {},
  className = '',
}) {
  const {play, stop} = useSVGA(`#${id}`, src)

  useEffect(() => {
    play?.()
    return () => stop?.()
  }, [id, play, src, stop])
  return <div id={id} style={style} className={className} />
}

export default memo(SVGAPlayer)

有了useSVGA,可以轻松的自定义个播放svga的组件——SVGAPlayer,有了这两个,再也不用重复写svga的播放了~当然了移动端如果不考虑过多的兼容性的话,svga播放还是推荐使用svga.lite(ps:同样可以写一个hooks,如useSVGALite)

打造符合自己业务的hooks库

一般来说,每个部门经过不同的业务后都会沉淀自己的技术,如请求、动画、图片、视频等处理方案,回顾一下整篇的中心思想——就是hooks的组合,所以在抽离hooks的时候,应该借鉴函数式编程,让每个hooks的功能越单一越好。

对于一些可以脱离业务的公用逻辑可以抽成通用hooks,如useTitle, usePersistFn,useBoolean等,事实上已经有不少不错的库了(如react-useumi hooks),对于一些通用的hooks,这些库都可以用来直接用,或者稍加改造后使用。(个人感觉是一切以业务为基础,避免重复造轮子,如果觉得人家的不ok,自己写那是必须的~)

对于一些必须基于自己业务的逻辑复用,可以根据自己业务定制化hooks,再结合一些通用hooks去组合hooks,如useSVGA、useHttp等,打造符合自己业务的hooks库。