写在前面
我们知道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-use、umi hooks),对于一些通用的hooks,这些库都可以用来直接用,或者稍加改造后使用。(个人感觉是一切以业务为基础,避免重复造轮子,如果觉得人家的不ok,自己写那是必须的~)
对于一些必须基于自己业务的逻辑复用,可以根据自己业务定制化hooks,再结合一些通用hooks去组合hooks,如useSVGA、useHttp等,打造符合自己业务的hooks库。