一、在谈 react hook 之前
React 的组件化给前端开发带来了前所未有的体验,我们可以像玩乐高玩具一样将组件堆积拼接起来,组成完整的 UI 界面,在加快开发速度的同时又提高了代码的可维护性。
但是随着业务功能复杂度提高,业务代码不得不和生命周期函数糅合到一起。这样很多重复的业务逻辑代码很难被抽离出来,为了快速开发不得不 Ctrl+C,如果业务代码逻辑发生变化时,我们又不得不同时修改多个地方,极大的影响开发效率和可维护性。为了解决这个业务逻辑复用的问题,React 官方也做了很多努力。
1.1 React.mixin
React mixin 是通过 React.createClass 创建组件时使用的,现在主流是通过 ES6 方式创建 react 组件,官方因为 mixin 不好追踪变化以及影响性能,所以放弃了对其支持,同时也不推荐使用。这里简单介绍下 mixin。
mixin 的原理其实就是将[mixin]里面的方法合并到组件的 prototype 上。
javascriptvar logMixin = { alertLog:function(){ alert('alert mixin...') }, componentDidMount:function(){ console.log('mixin did mount') }}
var MixinComponentDemo = React.createClass({ mixins:[logMixin], componentDidMount:function(){ document.body.addEventListener('click',()=>{ this.alertLog() }) console.log('component did mount') }})
// 打印如下// component did mount// mixin did mount// 点击页面// alert mixin
复制代码
可以看出来 mixin 就是将 logMixn 的方法合并到 MixinComponentDemo 组件中,如果有重名的生命周期函数都会执行(render 除外,如果重名会报错)。但是由于 mixin 的问题比较多这里不展开讲。点击了解更多。
1.2 高阶组件
组件是 React 中代码复用的基本单元。但你会发现某些模式并不适合传统组件。
例如:我们有个计时器和日志记录组件
javascriptclass LogTimeComponent extends React.Component{ constructor(props){ super(props); this.state = { index: 0 } this.show = 0; } componentDidMount(){ this.timer = setInterval(()=>{ this.setState({ index: ++index }) },1000) console.log('组件渲染完成----') } componentDidUpdate(){ console.log(`我背更新了${++this.show}`) } componentWillUnmount(){ clearInterval(this.timer) console.log('组件即将卸载----') } render(){ return( <div> <span>{`我已经显示了:${this.state.index}s`}</span> </div> ) }}
复制代码
上面就实现了简单的日志和计时器组件。那么问题来了,假如有三个组件分别是 LogComponent(需要记录日志)、SetTimeComponent(需要记录时间)、LogTimeShowComponent(日志和时间都需要记录),怎么处理呢?把上面逻辑 Ctrl+C 然后 Ctrl+V 吗?如果记录日志的文案改变需要每个组件都修改么?官方给我们提供了高阶组件(HOC)的解决方案:
javascriptfunction logTimeHOC(WrappedComponent,options={time:true,log:true}){ return class extends React.Component{ constructor(props){ super(props); this.state = { index: 0 } this.show = 0; } componentDidMount(){ options.time&&this.timer = setInterval(()=>{ this.setState({ index: ++index }) },1000) options.log&&console.log('组件渲染完成----') } componentDidUpdate(){ options.log&&console.log(`我背更新了${++this.show}`) } componentWillUnmount(){ this.timer&&clearInterval(this.timer) options.log&&console.log('组件即将卸载----') } render(){ return(<WrappedComponent {...this.state} {...this.props}/>) } }}
复制代码
logTimeHOC 就是一个函数,接受一个组件返回一个新的组件(其实高阶组件就是一个函数)。我们用这个高阶组件来构建我们上面的三个组件:
LogComponent:打印日志组件
javascriptclass InnerLogComponent extends React.Component{ render(){ return( <div>我是打印日志组件</div> ) }}// 使用高阶组件`logTimeHOC`包裹下 export default logTimeHOC(InnerLogComponent,{log:true})
复制代码
SetTimeComponent:计时组件
javascriptclass InnerSetTimeComponent extends React.Component{ render(){ return( <div> <div>我是计时组件</div> <span>{`我显示了${this.props.index}s`}</span> </div> ) }}// 使用高阶组件`logTimeHOC`包裹下 export default logTimeHOC(InnerSetTimeComponent,{time:true})
复制代码
LogTimeShowComponent:计时+打印日志组件
javascriptclass InnerLogTimeShowComponent extends React.Component{ render(){ return( <div> <div>我是日志打印+计时组件</div> </div> ) }}// 使用高阶组件`logTimeHOC`包裹下 export default logTimeHOC(InnerLogTimeShowComponent)
复制代码
这样不仅复用了业务逻辑提高了开发效率,同时还方便后期维护。当然上面的案例只是为了举例而写的案例,实际场景需要自己去合理抽取业务逻辑。高阶组件虽然很好用,但是也有一些自身的缺陷:
二、React Hook
上面说了很多,无非就是告诉我们已经有解决功能复用的方案了。为啥还要 React Hook 这个呢?上面例子可以看出来,虽然解决了功能复用但是也带来了其他问题。
由此官方带来 React Hook,它不仅仅解决了功能复用的问题,还让我们以函数的方式创建组件,摆脱 Class 方式创建,从而不必在被 this 的工作方式困惑,不必在不同生命周期中处理业务。
javascriptimport React,{ useState, useEffect } from 'react'function useLogTime(data={log:true,time:true}){ const [count,setCount] = useState(0); useEffect(()=>{ data.log && console.log('组件渲染完成----') let timer = null; if(data.time){ timer = setInterval(()=>{setCount(c=>c+1)},1000) } return ()=>{ data.log && console.log('组件即将卸载----') data.time && clearInterval(timer) } },[]) return {count}}
复制代码
我们通过 React Hook 的方式重新改写了上面日志时间记录高阶组件。如果不了解 React Hook 的基本用法建议先阅读react hook文档。如果想深入了解 setInterval 在 Hook 中的表现可以看这篇重新 Think in Hooks。
假设我们已经掌握了 React Hook,那么我来重写下上面的三个组件:
LogComponent:打印日志组件
javascriptexport default function LogComponent(){ useLogTime({log:true}) return( <div>我是打印日志组件</div> )}
复制代码
SetTimeComponent:计时组件
javascriptexport default function SetTimeComponent (){ const {count} = useLogTime({time:true}) return( <div> <div>我是计时组件</div> <span>{`我显示了${count}s`}</span> </div> )}
复制代码
LogTimeShowComponent:计时+打印日志组件
javascriptexport default function LogTimeShowComponent (){ const {count} = useLogTime() return( <div> <div>我是日志打印+计时组件</div> <div>{`我显示了${count}s`}</div> </div> )}
复制代码
我们用 React Hook 实现的这三个组件和高阶组件一比较,是不是发现更加清爽,更加 PF。将日志打印和记录时间功能抽象出一个 useLogTime 自定义 Hook。如果其他组件需要打印日志或者记录时间,只要直接调用 useLogTime 这个自定义 Hook 就可以了。是不是有种封装函数的感觉。
2.1 React Hook 实现原理
如果让我们来实现一个 React Hook,如何实现呢?好像毫无头绪,可以先看一个简单的 useState:(这部分内容只是帮我们更好的理解 Hook 工作原理,想了解 Hook 最佳实践可以直接查看 React 生产应用)
javascript function App(){ const [count,setCount] = useState(0); useEffect(()=>{ console.log(`update--${count}`) },[count]) return( <div> <button onClick={()=>setCount(count+1)}> {`当前点击次数:${count}`} </button> </div> ) }
复制代码
上面可以看出来当调用 useState 时,会返回一个变量和一个函数,其参数为返回变量的默认值。我们先构建如下的 useState 函数:
javascriptfunction useState(initVal) { let val = initVal; function setVal(newVal) { val = newVal; render(); // 修改val后 重新渲染页面 } return [val, setVal];}
复制代码
我们可以在代码中来使用 useState–查看demo。
不出意外当我们点击页面上的按钮时候,按钮中数字并不会改变;看控制台中每次点击都会输出 0,说明 useState 是执行了。由于 val 是在函数内部被声明的,每次 useState 都会重新声明 val 从而导致状态无法被保存,因此我们需要将 val 放到全局作用域声明。
javascriptlet val; // 放到全局作用域function useState(initVal) { val = val|| initVal; // 判断val是否存在 存在就使用 function setVal(newVal) { val = newVal; render(); // 修改val后 重新渲染页面 } return [val, setVal];}
复制代码
修改 useState 后,点击按钮时按钮就发生改变了–修改后Demo。
useEffect 是一个函数,有两个参数一个是函数,一个是可选参数-数组,根据第二个参数中是否有变化,来判断是否执行第一个参数的函数:
javascript// 实现第一版 不考虑第二个参数function useEffect(fn){ fn();}
复制代码
ok!不考虑第二个参数很简单,其实就是执行下函数–这里查看Demo(控制台中能看到 useEffect 执行了)。但是我们需要根据第二个参数来判断是否执行,而不是一直执行。所以我们还需要有一个判断逻辑去执行函数。
javascriptlet watchArr; // 为了记录状态变化 放到全局作用域function useEffect(fn,watch){ // 判断是否变化 const hasWatchChange = watchArr? !watch.every((val,i)=>{ val===watchArr[i] }):true; if( hasWatchChange ){ fn(); watchArr = watch; }}
复制代码
完成好 useEffect 我们再去测试下 –测试demo。
打开测试页面每次点击按钮,控制台会打印当前更新的 count;到目前为止,我们模拟实现了 useState 和 useEffect 可以正常工作了。不知道大家是否还记得我们通过全局变量来保证状态的实时更新;如果组件中要多次调用,就会发生变量冲突的问题,因为他们共享一个全局变量。如何解决这个问题呢?
javascript// 通过数组维护变量let memoizedState = [];let currentCursor = 0;
function useState(initVal) { memoizedState[currentCursor] = memoizedState[currentCursor] || initVal; function setVal(newVal) { memoizedState[currentCursor] = newVal; render(); } // 返回state 然后 currentCursor+1 return [memoizedState[currentCursor++], setVal]; }
function useEffect(fn, watch) { const hasWatchChange = memoizedState[currentCursor] ? !watch.every((val, i) => val === memoizedState[currentCursor][i]) : true; if (hasWatchChange) { fn(); memoizedState[currentCursor] = watch; currentCursor++; // 累加 currentCursor }}
复制代码
修改核心是将 useState,useEffect 按照调用的顺序放入 memoizedState 中,每次更新时,按照顺序进行取值和判断逻辑–查看Demo。
如上图我们根据调用 hook 顺序,将 hook 依次存入数组 memoizedState 中,每次存入时都是将当前的 currentcursor 作为数组的下标,将其传入的值作为数组的值,然后在累加 currentcursor,所以 hook 的状态值都被存入数组中 memoizedState。
上面状态更新图,我们可以看到执行 setCount(count + 1)或 setData(data + 2)时,先将旧数组 memoizedState 中对应的值取出来重新复值,从而生成新数组 memoizedState。对于是否执行 useEffect 通过判断其第二个参数是否发生变化而决定的。
这里我们就知道了为啥官方文档介绍:不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层调用他们。因为我们是根据调用 hook 的顺序依次将值存入数组中,如果在判断逻辑循环嵌套中,就有可能导致更新时不能获取到对应的值,从而导致取值混乱。同时 useEffect 第二个参数是数组,也是因为它就是以数组的形式存入的。
当然,react 官方不会像我们这么粗暴的方式去实现的,想了解官方是如何实现可以去这里查看。
三、React 生产应用
在说到 React 实际工作应用之前,希望你能对 React Hook 有做过了解,知道如 useState、useEffect、useContext 等基本 Hook 的使用,以及如何自定义 Hook,如果不了解可以点击这里了解关于Hook的知识点。
3.1 如何模拟 React 的生命周期
constructor:函数组件不需要构造函数。你可以通过调用 useState 来初始化 state。
componentDidMount:通过 useEffect 传入第二个参数为[]实现。
componentDidUpdate:通过 useEffect 传入第二个参数为空或者为值变动的数组。
componentWillUnmount:主要用来清除副作用。通过 useEffect 函数 return 一个函数来模拟。
shouldComponentUpdate:你可以用 React.memo 包裹一个组件来对它的 props 进行浅比较。来模拟是否更新组件。
componentDidCatch and getDerivedStateFromError:目前还没有这些方法的 Hook 等价写法,但很快会加上。
3.2 如何通过 React Hook 进行数据请求
前端页面免不了要和数据打交道,在 Class 组件中我们通常都是在 componentDidMount 生命周期中发起数据请求,然而我们使用 Hook 时该如何发送请求呢?
javascriptimport React,{ useState,useEffect } from 'react';
export default function App() { const [data, setData] = useState(null); useEffect(() => { const fetchData = async () => { const result = await axios( "https://easy-mock.com/mock/5b514734fe14b078aee5b189/example/queryList" ); setData(result.data); // 赋值获取后的数据 }; fetchData(); });
return ( <div> {data ? ( <ul> <li>{`id:${data.id}`}</li> <li>{`title:${data.title}`}</li> </ul> ) : null} </div> );}
复制代码
可以查看Demo,发现页面报错。根据我们了解到的知识,如果 useEffect 第二个参数不传入,导致每次 data 更新都会执行,这样就陷入死循环循环了。需要改造下:
javascript...
useEffect(() => {
...
},[]);
'''
复制代码
给第二个参数加上一个[]发现页面就可以显示了,将这个Demo中注释解除了。我们就可以发现页面正常显示了。
我们一个程序会有多个组件,很多组件都会有请求接口的逻辑,不能每个需要用到这个逻辑的时候都重新写或者 Ctrl+C。所以我们需要将这个逻辑抽离出来作为一个公共的 Hook 来调用,那么我们就要用到自定义 Hook。
javascript// config => 期望格式// {// method: 'post',// url: '/user/12345',// data: {// firstName: 'Fred',// lastName: 'Flintstone'// }// }function useFetchHook(config){ const [data,setData] = useState(null); useEffect(() => { const fetchData = async () => { const result = await axios(config); setData(result.data) }; fetchData(); },[]); return { data }}
复制代码
现在我们就将请求接口的逻辑单独抽出来了,如果那个组件需要使用可以直接引入 useFetchHook这里可以查看Demo。
上面的 useFetchHook 虽然可以解决我们请求接口的问题,如果现在是一个分页接口,每次传入不同的 page 都会重新请求,所以我们还需要修改下:
javascript// watch => 期望格式是 []function useFetchHook(config,watch){ const [data,setData] = useState(null); useEffect(() => { ... }, watch?[...watch]:[] // 判断是否有需要监测的属性 ); return { data }}
复制代码
点击查看Demo,我们现在点击页面上的按钮发现页面的数据户一直发生变化,控制台也会打印,说明我们更改 page 时都会重新请求接口,上面的问题就解决了。
上面的 useFetchHook 虽然可以解决大部分情况,但是一个健全的接口请求 Hook 还需要告知使用者接口请求状态的成功、失败。我们继续:
javascriptfunction useFetchHook(config,watch){ // status 标识当前接口请求状态 0:请求中 1:请求成功 2:请求失败 const [status,setStatus] = useState(0); const [data,setData] = useState(null); useEffect(() => { try{ ... setStatus(1) // 成功 }catch(err){ setStatus(2) // 失败 } }, watch?[...watch]:[] // 判断是否有需要监测的属性 ); return { data, status }}
复制代码
点击这里可以查看,改造后发现页面按钮多了接口状态,点击时也会发生改变,为了测试失败状态,我们将 Chrome - network - Offine 改为 offine 状态,再次点击发现状态就变成 2(失败)。
还没有完呢,使用者知道了状态后可以做相应的 loading… 操作等等。但是对于接口的报错我们也可以做一个埋点信息或者给一个友善的提示—至于后面怎么写我相信大家都可以发挥自己的想象。下面是 useFetchHook 完整代码:
javascriptfunction useFetchHook(config, watch) { const [data, setData] = useState(null); const [status, setStatus] = useState(0); useEffect( () => { const fetchData = async () => { try { const result = await axios(config); setData(result.data); setStatus(1); } catch (err) { setStatus(2); } };
fetchData(); }, watch ? [watch] : [] ); return { data, status };}
复制代码
3.3 提高性能的操作
javascriptclass App extends Component{ render() { return <div> <Button onClick={ () => { console.log('do something'); }} /> </div>; }}
复制代码
上面 App 组件如果 props 发生改变时,就会重新渲染组件。如果这个修改并不涉及到 Button 组件,但是由于每次 render 的时候都会产生新的 onClick 函数,react 就认为其发生了改变,从而产生了不必要的渲染而引起性能浪费。
javascriptclass App extends Component{ constructor(){ super(); this.buttonClick = this.buttonClick.bind(this); } render() { return <div> <Button onClick={ this.buttonClick } /> </div>; }}
复制代码
在类组件中我们可以直接将函数绑定到 this 对象上。在 Hook 组件中怎么解决呢?
javascriptfunction App(){ const buttonClick = useCallback( () => { console.log('do something'),[] ) return( <div> <Button onClick={ buttonClick } /> </div> )}
复制代码
如上直接用 useCallback 生成一个记忆函数,这样更新时就不会发生渲染了。在 react Hook 中 还有一个 useMemo 也能实现同样的效果。
3.4 React Hook 实现一个简版的 redux
React 是从上而下的单向数据流,父子组件之间信息传递可以通过 Props 实现,兄弟组件的信息传递我们可以将 Props 提升到共同的父级实现信息传递,如果组件层级嵌套过深,对开发者来说是十分痛苦的。
所以社区基于 redux 产生了 react-redux 工具,当然我们这里并不是要介绍 react-redux 的原理和实现,只是为解决上面痛点提供一种新的思路。
这里提供体验地址。
javascript// 创建Contextconst AppContext = React.createContext();const AppDispatch = (state, action) => { switch (action.type) { case "count.add": return { ...state, count: state.count + 1 }; case "count.reduce": return { ...state, count: state.count - 1 }; case "color": return { ...state, color: colorArr[getRandom()] }; default: return state; }};// 创建Providerconst AppProvider = props => { let [state, dispatch] = useReducer(AppDispatch, context); return ( <AppContext.Provider value={{ state, dispatch }}> {props.children} </AppContext.Provider> );};// ...function Demo3() { // 使用 Context const { state, dispatch } = useContext(AppContext); return ( <div className="demo" style={{ backgroundColor: state.color }} onClick={() => { dispatch({ type: "count.add" }); dispatch({ type: "color" }); }} > <div className="font">{state.count}</div> </div> );}// ...// 将 AppProvider 放到根组件ReactDOM.render( <AppProvider> <App /> </AppProvider>, rootElement);
复制代码
完整代码在这里获取。
3.5 一起来封装常用的 Hook
在开始封装常用 Hook 之前插一个题外话,我们在开发中时,不可能都是新项目,对于那些老项目(react 已经升级到 16.8.x)我们应该如何去使用 Hook 呢?
很简单,我们可以开发一些常用的 hook,当老项目有新的功能完全可以用 Hook 去开发,如果对老的组件进行修改时就可以考虑给老组件上 Hook,不建议一上来就进行大改。随着常用 Hook 组件库的丰富,后期改起来也会非常快。
在使用 Hook 时难免少不了一些常用的 Hook,如果可以将这些常用的 Hook 封装起来岂不是美滋滋!
首先可以创建如下目录结构:
index.js 文件
javascriptimport useInterval from './useInterval'// ...export{ useInterval // ...}
复制代码
lib 中存放常用 Hook,如实现一个 useInterval:为啥我们需要一个 useInterval 的自定义 Hook 呢?
在程序中直接使用 setInterval
javascriptfunction App(){ const [count,setCount] = useState(0); useEffect(()=>{ console.log(count) setInterval(()=>{ setCount(count+1) }) }) return <p>{count}</p>}
复制代码
上面代码直接运行我们会发现页面上的 count 越加越快,是由于 count 每次发生改变都导致定时器触发。所以需要每次在清除下定时器:
javascriptfunction App(){ const [count,setCount] = useState(0); useEffect(()=>{ console.log(count) const timer = setInterval(()=>{ setCount(count+1) }) // 清除副作用 return ()=>{ clearInterval(timer) } }) return <p>{count}</p>}
复制代码
改动代码后页面好像可以正常显示了,我们打开控制台可以看到一直会打印 count ,这样对于性能来将无疑是一种浪费,我们只需要执行一次就可以了,再改下代码:
javascriptfunction App(){ const [count,setCount] = useState(0); useEffect(()=>{ console.log(count) const timer = setInterval(()=>{ setCount(count+1) }) return ()=>{ clearInterval(timer) } },[]) // 添加第二个参数 return <p>{count}</p>}
复制代码
再看页面,发现控制台好像是只打印一次了,但是页面上的 count 以及不发生改变了,这不是我们想要的,还需要改变下:
javascriptfunction App(){ const [count,setCount] = useState(0); useEffect(()=>{ console.log(count) const timer = setInterval(()=>{ setCount(count+1) }) return ()=>{ clearInterval(timer) } },[count]) // 添加 count 变量 return <p>{count}</p>}
复制代码
Ok!现在好像解决了上面的问题了,但是这个只是一个定时器累加的任务而且只涉及到一个变量,如果是定时执行其他任务,同时有多个变量,那么岂不是又要修改。所以为了解决这个问题迫切需要一个 useInterval 自定义钩子。
javascriptfunction useInterval(callback,time=300){ const intervalFn = useRef(); // 1 useEffect(()=>{ intervalFn.current = callback; // 2 }) useEffect(()=>{ const timer = setInterval(()=>{ intervalFn.current() },time) return ()=>{ clearInterval(timer) } },[time]) // 3}
复制代码
自定义useInterval钩子体验地址。
简单介绍下 useInterval 钩子:
1、通过 useRef 创建一个对象;
2、将需要执行的定时任务储存在这个对象上;
3、将 time 作为第二个参数是为了当我们动态改变定时任务时,能过重新执行定时器。
开发中使用 useInterval 如下:
useInterval(() => { // you code}, 1000);
复制代码
是不是很简单又很方便,现在将 useInterval 放到 lib 文件夹中,再在 index.js 文件中导出一下,其他地方要用的时候直接 import 就可以了。
3.6 开放思维
问题:做一个 useImgLazy 的 hook 函数。
为提高网页的性能我们一般都会网页上图片资源做一些优化,懒加载就是一种方案,useImgLazy 就是实现懒加载的 Hook。
javascript// 判断是否在视口里面function isInWindow(el){ const bound = el.getBoundingClientRect(); const clientHeight = window.innerHeight; return bound.top <= clientHeight + 100;}// 加载图片真实链接function loadImg(el){ if(!el.src){ const source = el.getAttribute('data-sourceSrc'); el.src = source; }}// 加载图片function checkImgs(className){ const imgs = document.querySelectorAll(`img.${className}`); Array.from(imgs).forEach(el =>{ if (isInWindow(el)){ loadImg(el); } })}function useImgLazy(className){ useEffect(()=>{ window.addEventListener('scroll',()=>{ checkImgs(className) }); checkImgs(className);
return ()=>{ window.removeEventListener('scroll') } },[])}
复制代码
上面代码逻辑就是通过 getBoundingClientRect 获取图片元素的位置,从而判断是否显示图片真实地址,用 useEffect 模拟页面加载成功(onload 事件)同时监听 scroll 事件。
在需要使用图片懒加载的项目中使用:
javascriptfunction App(){ // ... useImgLazy('lazy-img') // ... return ( <div> // ... <img className='lazy-img' data-sourceSrc='真实图片地址'/> </div> )}
复制代码
以上 useImgLazy 代码我是写这篇文章时突然诞生的一个想法,没有验证,如果哪位同学验证后有问题还请告知我,在这里反馈问题。
我相信大家看了这篇文章一定会蠢蠢欲动,创建一个自定义 Hook 。点击这里你们使用过哪些自定义Hook函数,可以分享、学习其他人是如何自定义有趣的 Hook。
这里可以分享 Hook 的最佳实践,帮助我们更快的使用 React Hook。##说说Hook中的一些最佳实践##
作者介绍:
何鸣明,携程境外专车研发部前端工程师,主要负责境外打车、包车业务。喜欢交流分享。
本文转载自公众号携程技术(ID:ctriptech)。
原文链接:
https://mp.weixin.qq.com/s/xhXaj4j7aSxWuWfjIztoaQ
评论