在过去的几个月里,React Hooks 在咱们的项目中获得了充分利用。在实际使用过程当中,我发现 React Hooks 除了带来简洁的代码外,也存在对其使用不当的状况。html
在这篇文章中,我想总结我过去几个月来对 React Hooks 使用,分享我对它的见解以及我认为的最佳实践,供你们参考。前端
本文假定读者已经对 React-Hooks 及其使用方式有了初步的了解。您能够经过 官方文档 进行学习。react
简而言之,就是在一个函数中返回 React Element。git
const App = (props) => {
const { title } = props;
return (
<h1>{title}</h1>
);
};
复制代码
通常的,该函数接收惟一的参数:props 对象。从该对象中,咱们能够读取到数据,并经过计算产生新的数据,最后返回 React Elements 以交给 React 进行渲染。此外也能够选择在函数中执行反作用。github
在本文中,咱们给函数式组件的函数起个简单一点的名字:render 函数。数组
const appElement = App({ title: "XXX" });
ReactDOM.render(
appElement,
document.getElementById('app')
);
复制代码
在上方的代码中,咱们自行调用了 render 函数以期执行渲染。然而这在 React 中不是正常的操做。浏览器
正常操做是像下方这样的代码:缓存
// React.createElement(App, {
// title: "XXX"
// });
const appElement = <App title="XXX" />; ReactDOM.render( appElement, document.getElementById('app') ); 复制代码
在 React 内部,它会决定在什么时候调用 render 函数,并对返回的 React Elements 进行遍历,若是遇到函数组件,React 便会继续调用这个函数组件。在这个过程当中,能够由父组件经过 props 将数据传递到该子组件中。最终 React 会调用完全部的组件,从而知晓如何进行渲染。app
这种把 render 函数交给 React 内部处理的机制,为引入状态带来了可能。less
在本文中,为了方便描述,对于 render 函数的每次调用,我想称它为一帧。
在引入状态以前,咱们须要明白这一点。
咱们经过 例一 进行观察:
function Example(props) {
const { count } = props;
const handleClick = () => {
setTimeout(() => {
alert(count);
}, 3000);
};
return (
<div> <p>{count}</p> <button onClick={handleClick}>Alert Count</button> </div>
);
}
复制代码
重点关注 <Example>
函数组件的代码,其中的 count
属性由父组件传入,初始值为 0,每隔一秒增长 1。点击 "Alert Count" 按钮,将延迟 3 秒钟弹出 count
的值。操做后发现,弹窗中出现的值,与页面中文本展现的值不一样,而是等于点击 "alert Count" 按钮时 count
的值。
若是更换为 class 组件,它的实现是 <Example2>
这样的:
class Example2 extends Component {
handleClick = () => {
setTimeout(() => {
alert(this.props.count);
}, 3000);
};
render() {
return (
<div> <h2>Example2</h2> <p>{this.props.count}</p> <button onClick={this.handleClick}>Alert Count</button> </div>
);
}
}
复制代码
此时,点击 "Alert Count" 按钮,延迟 3 秒钟弹出 count
的值,与页面中文本展现的值是同样的。
在某些状况下,<Example>
函数组件中的行为才符合预期。若是将 setTimeout
类比到一次 Fetch 请求,在请求成功时,我要获取的是发起 Fetch 请求前相关的数据,并对其进行修改。
如何理解其中的差别呢?
在 <Example2>
class 组件中,咱们是从 this
中获取到的 props.count
。this
是固定指向同一个组件实例的。在 3 秒的延时器生效后,组件从新进行了渲染,this.props
也发生了改变。当延时的回调函数执行时,读取到的 this.props
是当前组件最新的属性值。
而在 <Example>
函数组件中,每一次执行 render 函数时,props
做为该函数的参数传入,它是函数做用域下的变量。
当 <Example>
组件被建立,将运行相似这样的代码来完成第一帧:
const props_0 = { count: 0 };
const handleClick_0 = () => {
setTimeout(() => {
alert(props_0.count);
}, 3000);
};
return (
<div> <h2>Example</h2> <p>{props_0.count}</p> <button onClick={handleClick_0}>alert Count</button> </div>
);
复制代码
当父组件传入的 count 变为 1,React 会再次调用 Example
函数,执行第二帧,此时 count
是 1
。
const props_1 = { count: 1 };
const handleClick_1 = () => {
setTimeout(() => {
alert(props_1.count);
}, 3000);
};
return (
<div> <h2>Example</h2> <p>{props_1.count}</p> <button onClick={handleClick_1}>alert Count</button> </div>
);
复制代码
因为 props
是 Example
函数做用域下的变量,能够说对于这个函数的每一次调用中,都产生了新的 props
变量,它在声明时被赋予了当前的属性,他们相互间互不影响。
换一种说法,对于其中任一个 props
,其值在声明时便已经决定,不会随着时间产生变化。handleClick
函数亦是如此。例如定时器的回调函数是在将来发生的,但 props.count
的值是在声明 handleClick
函数时就已经决定好的。
若是咱们在函数开头使用解构赋值,const { count } = props
,以后直接使用 count
,和上面的状况没有区别。
能够简单的认为,在某个组件中,对于返回的 React Elements 树形结构,某个位置的 element ,其类型与 key 属性均不变,React 便会选择重用该组件实例;不然,好比从 <A/>
组件切换到了 <B/>
组件,会销毁 A,而后重建 B,B 此时会执行第一帧。
在实例中,能够经过 useState
等方式拥有局部状态。在重用的过程当中,这些状态会获得保留。而若是没法重用,状态会被销毁。
例如 useState
,为当前的函数组件建立了一个状态,这个状态的值独立于函数存放。 useState
会返回一个数组,在该数组中,获得该状态的值和更新该状态的方法。经过解构,该状态的值会赋值到当前 render 函数做用域下的一个常量 state
中。
const [state, setState] = useState(initialState);
复制代码
当组件被建立而不是重用时,即在组件的第一帧中,该状态将被赋予初始值 initialState
,而以后的重用过程当中,不会被重复赋予初始值。
经过调用 setState
,能够更新状态的值。
须要明确的是,state
做为函数中的一个常量,就是普通的数据,并不存在诸如数据绑定这样的操做来驱使 DOM 发生更新。在调用 setState
后,React 将从新执行 render 函数,仅此而已。
所以,状态也是函数做用域下的普通变量。咱们能够说每次函数执行拥有独立的状态。
为了加深印象,咱们来看 例二,它是 React 官网某个例子的复杂化:
function Example2() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1);
}, 3000);
};
return (
<div> <p>{count}</p> <button onClick={() => setCount(count + 1)}> setCount </button> <button onClick={handleClick}> Delay setCount </button> </div>
);
}
复制代码
在第一帧中,p
标签中的文本为 0。点击 "Delay setCount",文本依然为 0。随后在 3 秒内连续点击 "setCount" 两次,将会分别执行第二帧和第三帧。你将看到 p
标签中的文本由 0 变化为 1, 2。但在点击 "Delay setCount" 3 秒后,文本从新变为 1。
// 第一帧
const count_1 = 0;
const handleClick_1 = () => {
const delayAction_1 = () => {
setCount(count_1 + 1);
};
setTimeout(delayAction_1, 3000);
};
//...
<button onClick={handleClick_1}>
//...
// 点击 "setCount" 后第二帧
const count_2 = 1;
const handleClick_2 = () => {
const delayAction_2 = () => {
setCount(count_2 + 1);
};
setTimeout(delayAction_2, 3000);
};
//...
<button onClick={handleClick_2}>
//...
// 再次点击 "setCount" 后第三帧
const count_3 = 2;
const handleClick_3 = () => {
const delayAction_3 = () => {
setCount(count_3 + 1);
};
setTimeout(delayAction_3, 3000);
};
//...
<button onClick={handleClick_3}>
//...
复制代码
count
,handleClick
都是 Example2
函数做用域中的常量。在点击 "Delay setCount" 时,定时器设置 3000ms 到期后的执行函数为 delayAction_1
,函数中读取 count_1
常量的值是 0,这和第二帧的 count_2
无关。
对于 state,若是想要在第一帧时点击 "Delay setCount" ,在一个异步回调函数的执行中,获取到 count
最新一帧中的值,不妨向 setCount
传入函数做为参数。
其余状况下,例如须要读取到 state 及其衍生的某个常量,相对于变量声明时所在帧过去或将来的值,就须要使用 useRef
,经过它来拥有一个在全部帧中共享的变量。
若是要与 class 组件进行比较,useRef
的做用相对于让你在 class 组件的 this
上追加属性。
const refContainer = useRef(initialValue);
复制代码
在组件的第一帧中,refContainer.current
将被赋予初始值 initialValue
,以后便再也不发生变化。但你能够本身去设置它的值。设置它的值不会从新触发 render 函数。
例如,咱们把第 n 帧的某个 props 或者 state 经过 useRef
进行保存,在第 n + 1 帧能够读取到过去的,第 n 帧中的值。咱们也能够在第 n + 1 帧使用 ref 保存某个 props 或者 state,而后在第 n 帧中声明的异步回调函数中读取到它。
对 例二 进行修改,获得 例三,看看具体的效果:
function Example() {
const [count, setCount] = useState(0);
const currentCount = useRef(count);
currentCount.current = count;
const handleClick = () => {
setTimeout(() => {
setCount(currentCount.current + 1);
}, 3000);
};
return (
<div> <p>{count}</p> <button onClick={() => setCount(count + 1)}> setCount </button> <button onClick={handleClick}> Delay setCount </button> </div>
);
}
复制代码
在 setCount
后便会执行下一帧,在函数的开头, currentCount
始终与最新的 count
state 保持同步。所以,在 setTimeout
中能够经过此方法获取到回调函数执行时当前的 count 值。
接下来再经过 例四 了解如何获取过去帧中的值:
function Example4() {
const [count, setCount] = useState(1);
const prevCountRef = useRef(1);
const prevCount = prevCountRef.current;
prevCountRef.current = count;
const handleClick = () => {
setCount(prevCount + count);
};
return (
<div> <p>{count}</p> <button onClick={handleClick}>SetCount</button> </div>
);
}
复制代码
这段代码实现的功能是,count 初始值为 1,点击按钮后累加到 2,随后点击按钮,老是用当前 count 的值和前一个 count 的值进行累加,获得新的 count 的值。
prevCountRef
在 render 函数执行的过程当中,与最新的 count
state 进行了同步。因为在同步前,咱们将该 ref 保存到函数做用域下的另外一个变量 prevCount
中,所以咱们老是可以获取到前一个 count 的值。
一样的方法,咱们能够用于保存任何值:某个 prop,某个 state 变量,甚至一个函数等。在后面的 Effects 部分,咱们会继续使用 refs 为咱们带来好处。
若是弄清了前面的『每一帧拥有独立的变量』的概念,你会发现,若某个 useEffect/useLayoutEffect 有且仅有一个函数做为参数,那么每次 render 函数执行时该 Effects 也是独立的。由于它是在 render 函数中选择适当时机的执行。
对于 useEffect
来讲,执行的时机是完成全部的 DOM 变动并让浏览器渲染页面后,而 useLayoutEffect
和 class 组件中 componentDidMount
, componentDidUpdate
一致——在 React 完成 DOM 更新后立刻同步调用,会阻塞页面渲染。
若是 useEffect 没有传入第二个参数,那么第一个参数传入的 effect 函数在每次 render 函数执行是都是独立的。每一个 effect 函数中捕获的 props 或 state 都来自于那一次的 render 函数。
咱们能够再观察一个例子:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
<div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div>
);
}
复制代码
在这个例子中,每一次对 count
进行改变,从新执行 render 函数后,延迟 3 秒打印 count
的值。
若是咱们不停地点击按钮,打印的结果是什么呢?
咱们发现通过延时后,每一个 count 的值被依次打印了,他们从 0 开始依次递增,且不重复。
若是换成 class 组件,尝试使用 componentDidUpdate
去实现,会获得不同的结果:
componentDidUpdate() {
setTimeout(() => {
console.log(`You clicked ${this.state.count} times`);
}, 3000);
}
复制代码
this.state.count
老是指向最新的 count
值,而不是属于某次调用 render 函数时的值。
所以,在使用 useEffect 时,应当抛开在 class 组件中关于生命周期的思惟。他们并不相同。在 useEffect 中刻意寻找那几个生命周期函数的替代写法,将会陷入僵局,没法充分发挥 useEffect 的能力。
React 针对 React Elements 先后值进行对比,只去更新 DOM 真正发生改变的部分。对于 Effects,可否有相似这样的理念呢?
某个 Effects 函数一旦执行,函数内的反作用已经发生,React 没法猜想到函数相比于上一次作了哪些变化。但咱们能够给 useEffect 传入第二个参数,做为依赖数组 (deps),避免 Effects 没必要要的重复调用。
这个 deps 的含义是:当前 Effect 依赖了哪些变量。
但有时问题不必定能解决。好比官网就有 这样的例子:
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
复制代码
若是咱们频繁修改 count
,每次执行 Effect,上一次的计时器被清除,须要调用 setInterval
从新进入时间队列,实际的按期时间被延后,甚至有可能根本没有机会被执行。
可是下面这样的实践方式也不宜采用:
在 Effect 函数中寻找一些变量添加到 deps 中,须要知足条件:其变化时,须要从新触发 effect。
按照这种实践方式,count
变化时,咱们并不但愿从新 setInterval
,故 deps 为空数组。这意味着该 hook 只在组件挂载时运行一次。Effect 中明明依赖了 count
,但咱们撒谎说它没有依赖,那么当 setInterval
回调函数执行时,获取到的 count
值永远为 0。
遇到这种问题,直接从 deps 移除是不可行的。静下来分析一下,此处为何要用到 count
?可否避免对其直接使用?
能够看到,在 setCount
中用到了 count
,为的是把 count
转换为 count + 1
,而后返回给 React。React 其实已经知道当前的 count
,咱们须要告知 React 的仅仅是去递增状态,无论它如今具体是什么值。
因此有一个最佳实践:状态变动时,应该经过 setState 的函数形式来代替直接获取当前状态。
setCount(c => c + 1);
复制代码
另一种场景是:
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []);
复制代码
在这里,一样的,当count
变化时,咱们并不但愿从新 setInterval
。但咱们能够把 count
经过 ref 保存起来。
const [count, setCount] = useState(0);
const countRef = useRef();
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
复制代码
这样,count
的确再也不被使用,而是用 ref 存储了一个在全部帧中共享的变量。
另外的状况是,Effects 依赖了函数或者其余引用类型。与原始数据类型不一样的是,在未优化的状况下,每次 render 函数调用时,由于对这些内容的从新建立,其值老是发生了变化,致使 Effects 在使用 deps 的状况下依然会频繁被调用。
对于这个问题,官网的 FAQ 已经给出了答案:对于函数,使用 useCallback 避免重复建立;对于对象或者数组,则可使用 useMemo。从而减小 deps 的变化。
使用 ESLint 插件 eslint-plugin-react-hooks@>=2.4.0
,颇有必要。
该插件除了帮你检查使用 Hook 须要遵循的两条规则外,还会向你提示在使用 useEffect 或者 useMemo 时,deps 应该填入的内容。
若是你正在使用 VSCode,而且安装了 ESLint 扩展。当你编写 useEffect 或者 useMemo ,且 deps 中的内容并不完整时,deps 所在的那一行便会给出警告或者错误的提示,而且会有一个快速修复的功能,该功能会为你自动填入缺失的 deps。
对于这些提示,不要暴力地经过 eslint-disable
禁用。将来,你可能再次修改该 useEffect 或者 useMemo,若是使用了新的依赖而且在 deps 中漏掉了它,便会引起新的问题。有一些场景,好比 useEffect 依赖一个函数,而且填入 deps 了。可是这个函数使用了 useCallback 且 deps 出现了遗漏,这种状况下一旦出现问题,排查的难度会很大,因此为何要让 ESLint 沉默呢?
尝试用上一节的方法进行分析,对于一些变量不但愿引发 effect 从新更新的,使用 ref 解决。对于获取状态用于计算新的状态的,尝试 setState 的函数入参,或者使用 useReducer 整合多个类型的状态。
useMemo 的含义是,经过一些变量计算获得新的值。经过把这些变量加入依赖 deps,当 deps 中的值均未发生变化时,跳过此次计算。useMemo 中传入的函数,将在 render 函数调用过程被同步调用。
可使用 useMemo 缓存一些相对耗时的计算。
除此之外,useMemo 也很是适合用于存储引用类型的数据,能够传入对象字面量,匿名函数等,甚至是 React Elements。
const data = useMemo(() => ({
a,
b,
c,
d: 'xxx'
}), [a, b, c]);
// 能够用 useCallback 代替
const fn = useMemo(() => () => {
// do something
}, [a, b]);
const memoComponentsA = useMemo(() => (
<ComponentsA {...someProps} /> ), [someProps]); 复制代码
在这些例子中,useMemo 的目的实际上是尽可能使用缓存的值。
对于函数,其做为另一个 useEffect 的 deps 时,减小函数的从新生成,就能减小该 Effect 的调用,甚至避免一些死循环的产生;
对于对象和数组,若是某个子组件使用了它做为 props,减小它的从新生成,就能避免子组件没必要要的重复渲染,提高性能。
未优化的代码以下:
const data = { id };
return <Child data={data}>; 复制代码
此时,每当父组件须要 render 时,子组件也会执行 render。若是使用 useMemo
对 data 进行优化:
const data = useMemo(() => ({ id }), [id]);
return <Child data={data}>; 复制代码
当父组件 render 时,只要知足 id 不变,data 的值也不会发生变化,子组件也将避免 render。
对于组件返回的 React Elements,咱们能够选择性地提取其中一部分 elements,经过 useMemo 进行缓存,也能避免这一部分的重复渲染。
在过去的 class 组件中,咱们经过 shouldComponentUpdate
判断当前属性和状态是否和上一次的相同,来避免组件没必要要的更新。其中的比较是对于本组件的全部属性和状态而言的,没法根据 shouldComponentUpdate
的返回值来使该组件一部分 elements 更新,另外一部分不更新。
为了进一步优化性能,咱们会对大组件进行拆分,拆分出的小组件只关心其中一部分属性,从而有更多的机会不去更新。
而函数组件中的 useMemo 其实就能够代替这一部分工做。为了方便理解,咱们来看 例五:
function Example(props) {
const [count, setCount] = useState(0);
const [foo] = useState("foo");
const main = (
<div>
<Item key={1} x={1} foo={foo} />
<Item key={2} x={2} foo={foo} />
<Item key={3} x={3} foo={foo} />
<Item key={4} x={4} foo={foo} />
<Item key={5} x={5} foo={foo} />
</div>
);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>setCount</button>
{main}
</div>
);
}
复制代码
假设 <Item>
组件,其自身的 render 消耗较多的时间。默认状况下,每次 setCount 改变 count 的值,便会从新对 <Example>
进行 render,其返回的 React Elements 中3个 <Item>
也从新 render,其耗时的操做阻塞了 UI 的渲染。致使按下 "setCount" 按钮后出现了明显的卡顿。
为了优化性能,咱们能够将 main
变量这一部分单独做为一个组件 <Main>
,拆分出去,并对 <Main>
使用诸如 React.memo
, shouldComponentUpdate
的方式,使 count
属性变化时,<Main>
不重复 render。
const Main = React.memo((props) => {
const { foo }= props;
return (
<div>
<Item key={1} x={1} foo={foo} />
<Item key={2} x={2} foo={foo} />
<Item key={3} x={3} foo={foo} />
<Item key={4} x={4} foo={foo} />
<Item key={5} x={5} foo={foo} />
</div>
);
});
复制代码
而如今,咱们可使用 useMemo
,避免了组件拆分,代码也更简洁易懂:
function Example(props) {
const [count, setCount] = useState(0);
const [foo] = useState("foo");
const main = useMemo(() => (
<div>
<Item key={1} x={1} foo={foo} />
<Item key={2} x={2} foo={foo} />
<Item key={3} x={3} foo={foo} />
<Item key={4} x={4} foo={foo} />
<Item key={5} x={5} foo={foo} />
</div>
), [foo]);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>setCount</button>
{main}
</div>
);
}
复制代码
对于 state,其拥有 惰性初始化的方法。可能有人不明白它的做用。
someExpensiveComputation
是一个相对耗时的操做。若是咱们直接采用
const initialState = someExpensiveComputation(props);
const [state, setState] = useState(initialState);
复制代码
注意,虽然 initialState
只在初始化时有其存在的价值,可是 someExpensiveComputation
在每一帧都被调用了。只有当使用惰性初始化的方法:
const [state, setState] = useState(() => {
const initialState = someExpensiveComputation(props);
return initialState;
});
复制代码
因 someExpensiveComputation
运行在一个匿名函数下,该函数当且仅当初始化时被调用,从而优化性能。
咱们甚至能够跳出计算 state 这一规定,来完成任何昂贵的初始化操做。
useState(() => {
someExpensiveComputation(props);
return null;
});
复制代码
当 useEffect
的依赖频繁变化,你可能想到把频繁变化的值用 ref 保存起来。然而,useReducer 多是更好的解决方式:使用 dispatch 消除对一些状态的依赖。官网的 FAQ 有详细的解释。
最终能够总结出这样的实践:
useEffect 对于函数依赖,尝试将该函数放置在 effect 内,或者使用 useCallback 包裹;useEffect/useCallback/useMemo,对于 state 或者其余属性的依赖,根据 eslint 的提示填入 deps;若是不直接使用 state,只是想修改 state,用 setState 的函数入参方式(setState(c => c + 1)
)代替;若是修改 state 的过程依赖了其余属性,尝试将 state 和属性聚合,改写成 useReducer 的形式。当这些方法都不奏效,使用 ref,可是依然要谨慎操做。
使用 useMemo 当 deps 不变时,直接返回上一次计算的结果,从而使子组件跳过渲染。
可是当返回的是原始数据类型(如字符串、数字、布尔值)。即便参与了计算,只要 deps 依赖的内容不变,返回结果也极可能是不变的。此时就须要权衡这个计算的时间成本和 useMemo 额外带来的空间成本(缓存上一次的结果)了。
此外,若是 useMemo 的 deps 依赖数组为空,这样作说明你只是但愿存储一个值,这个值在从新 render 时永远不会变。
好比:
const Comp = () => {
const data = useMemo(() => ({ type: 'xxx' }), []);
return <Child data={data}>; } 复制代码
能够被替换为:
const Comp = () => {
const { current: data } = useRef({ type: 'xxx' });
return <Child data={data}>; } 复制代码
甚至:
const data = { type: 'xxx' };
const Comp = () => {
return <Child data={data}>; } 复制代码
此外,若是 deps 频繁变更,咱们也要思考,使用 useMemo 是否有必要。由于 useMemo 占用了额外的空间,还须要在每次 render 时检查 deps 是否变更,反而比不使用 useMemo 开销更大。
在一个自定义 Hooks,咱们可能有这样一段逻辑:
useSomething = (inputCount) => {
const [ count, setCount ] = setState(inputCount);
};
复制代码
这里有一个问题,外部传入的 inputCount
属性发生了变化,使其与 useSomething
Hook 内的 count
state 不一致时,是否想要更新这个 count
?
默认不会更新,由于 useState 参数表明的是初始值,仅在 useSomething
初始时赋值给了 count
state。后续 count
的状态将与 inputCount
无关。这种外部没法直接控制 state 的方式,咱们称为非受控。
若是想被外部传入的 props 始终控制,好比在这个例子中,useSomething
内部,count
这一 state 的值须要从 inputCount
进行同步,须要这样写:
useSomething = (inputCount) => {
const [ count, setCount ] = setState(inputCount);
setCount(inputCount);
};
复制代码
setCount
后,React 会当即退出当前的 render 并用更新后的 state 从新运行 render 函数。这一点,官网文档 是有说明的。
在这种的机制下,state 由外界同步的同时,内部又有可能经过 setState 来修改 state,可能引起新的问题。例如 useSomething
初始时,count 为 0,后续内部经过 setCount
修改了 count
为 1。当外部函数组件的 render 函数从新调用,也会再一次调用 useSomething
,此时传入的 inputCount
依然是 0,就会把 count
变回 0。这极可能不符合预期。
遇到这样的问题,建议将 inputCount
的当前值与上一次的值进行比较,只有肯定发生变化时执行 setCount(inputCount)
。
固然,在特殊的场景下,这样的设定也不必定符合需求。官网的这篇文章 有提出相似的问题。
经过一个滑动选择器自定义 hook userSlider
的实现,咱们能够回答上面的这个问题,顺便对本文作一个总结。
userSlider
须要实现的逻辑是:按住滑动选择器的圆形手柄区域并拖动能够调节数值大小,数值范围为 0 到 1。
userSlider
只负责逻辑的实现,UI 样式由组件自行完成。为了模拟真实业务,另外经过文本展现了当前的数值。并有几个按钮用于切换数值的初始值,这是为了切换分类后,当前的滑动选择器须要重置到某个数值。
按照常规的逻辑,咱们实现了如下代码:
当前的问题是,useEffect
涉及到多个 state 的获取与计算。致使鼠标按下、移动、弹起的几个操做中由于对 stata 的修改,useEffect
频繁刷新,且涉及到了鼠标按下、移动、弹起事件监听的取消与从新绑定,这带来了性能问题以及较难观察到的 BUG。
和前面的 setInterval
例子类似,咱们不但愿在状态变更时,刷新 useEffect
。因为此处涉及到多个状态:是否滑动中、鼠标位置、上一次鼠标的问题、选择器的可滑动宽度,若是整合到一个 state
中,会面临代码不清晰,缺乏内聚性的问题,咱们尝试用 useReducer
作一次替换。
const reducer = (state, action) => {
switch (action.type) {
case "start":
return {
...state,
lastPos: action.x,
slideRange: action.slideWidth,
sliding: true
};
case "move": {
if (!state.sliding) {
return state;
}
const pos = action.x;
const delta = pos - state.lastPos;
return {
...state,
lastPos: pos,
ratio: fixRatio(state.ratio + delta / state.slideRange)
};
}
case "end": {
if (!state.sliding) {
return state;
}
const pos = action.x;
const delta = pos - state.lastPos;
return {
...state,
lastPos: pos,
ratio: fixRatio(state.ratio + delta / state.slideRange),
sliding: false
};
}
default:
return state;
}
};
//...
const handleThumbMouseDown = useCallback(ev => {
const hotArea = hotAreaRef.current;
dispatch({
type: "start",
x: ev.pageX
slideWidth: hotArea.clientWidth
});
}, []);
useEffect(() => {
const onSliding = ev => {
dispatch({
type: "move",
x: ev.pageX
});
};
const onSlideEnd = ev => {
dispatch({
type: "end",
x: ev.pageX
});
};
document.addEventListener("mousemove", onSliding);
document.addEventListener("mouseup", onSlideEnd);
return () => {
document.removeEventListener("mousemove", onSliding);
document.removeEventListener("mouseup", onSlideEnd);
};
}, []);
复制代码
这样处理后,effect 只要执行一次便可。
接下来还有一个问题没有处理,目前 initRatio
是做为初始值传入的,useSlider
内部的 ratio 是不受外部控制的。
以一个音乐均衡器的设置为例:当前滑动选择器表明的是低频端(31)的增益值,用户经过拖动滑块能够设置这个值的大小(-12 到 12 dB 范围,咱们设置到了 3 dB)。同时咱们提供了一些预设选项,一旦选择预设选项,如『流行』风格,当前滑块须要重置到特定值 -1 dB。为此, useSlider
须要提供控制状态的方法。
根据前一节的介绍,在 useSlider
的开头,咱们能够将属性 initRatio
的当前值与上一次的值进行比较,若发生变化,则执行 setRatio
。但仍然有场景没法知足:用户选择了『流行』这一预设,而后拖动滑块进行了调节,以后又从新选择『流行』这一预设,此时 initRatio
没有任何变化,但咱们指望 ratio 从新变为 initRatio
。
解决这个问题的办法是,在 useSlider
内部添加一个 setRatio
方法。
const setRatio = useCallback(
ratio =>
dispatch({
type: "setRatio",
ratio
}),
[]
);
复制代码
将该方法输出供外部用于对 ratio 控制。initRatio
再也不控制 ratio 的状态,仅用于设置初始值。
能够看下最终的实现方案:
该方案中,除了完成以上需求,还支持在选择器的其余区域点击直接跳转到对应的数值;支持设定选择器为垂直仍是水平方向。供你们参考。
忘掉 class 组件的生命周期,从新审视函数式组件的意义,是用好 React Hooks 的关键一步。但愿这篇文章能帮助你们进一步理解并获取到一些最佳实践。固然,不一样的 React Hooks 使用姿式可能带来不一样的最佳实践,欢迎你们交流。
本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们!