需求分析
我們實現了一個這樣的功能
- 點擊 Start 開始執行 interval,並且一旦有可能就往 lapse 上加一
- 點擊 Stop 后取消 interval
- 點擊 Clear 會取消 interval,並且設置 lapse 為 0
import React from 'react'
import ReactDOM from 'react-dom'
const buttonStyles = {
border: '1px solid #ccc',
background: '#fff',
fontSize: '2em',
padding: 15,
margin: 5,
width: 200,
}
const labelStyles = {
fontSize: '5em',
display: 'block',
}
function Stopwatch() {
const [lapse, setLapse] = React.useState(0)
const [running, setRunning] = React.useState(false)
React.useEffect(() => {
if (running) {
const startTime = Date.now() - lapse
const intervalId = setInterval(() => {
setLapse(Date.now() - startTime)
}, 0)
return () => {
clearInterval(intervalId)
}
}
}, [running])
function handleRunClick() {
setRunning(r => !r)
}
function handleClearClick() {
setRunning(false)
setLapse(0)
}
if (!running) console.log('running is false')
return (
<div>
<label style={labelStyles}>{lapse}ms</label>
<button onClick={handleRunClick} style={buttonStyles}>
{running ? 'Stop' : 'Start'}
</button>
<button onClick={handleClearClick} style={buttonStyles}>
Clear
</button>
</div>
)
}
function App() {
const [show, setShow] = React.useState(true)
return (
<div style={{textAlign: 'center'}}>
<label>
<input
checked={show}
type="checkbox"
onChange={e => setShow(e.target.checked)}
/>{' '}
Show stopwatch
</label>
{show ? <Stopwatch /> : null}
</div>
)
}
ReactDOM.render(<App />, document.getElementById('root'))
問題描述
1.我們首先點擊start,2.然后點擊clear,3.發現問題:顯示的並不是0ms
問題分析
為什么通過clear設置了值為0,卻顯示的不是0?
出現這樣的情況主要原因是:useEffect 是異步的,也就是說我們執行 useEffect 中綁定的函數或者是解綁的函數,都不是在一次 setState 產生的更新中被同步執行的。啥意思呢?我們來模擬一下代碼的執行順序:
1.在我們點擊來 clear 之后,我們調用了 setLapse 和 setRunning,這兩個方法是用來更新 state 的,所以他們會標記組件更新,然后通知 React 我們需要重新渲染來。
2.然后 React 開始來重新渲染的流程,並很快執行到了 Stopwatch 組件。
3.先執行了Stopwatch組件中的同步組件,然后執行異步組件,因此通過clear設置的0被渲染,然后即將執行useEffect中的異步事件,由於在執行清除interval之前,interval還存在,因此它計算了最新的值,並把通過clear設置的0給更改了並渲染出來,然后才清除。
順序大概是這樣的:
useEffect:setRunning(false) => setLapse(0) => render(渲染) => 執行Interval => (clearInterval => 執行effect) => render(渲染)
問題解決
方法1:使用useLayoutEffect
useLayoutEffect 可以看作是 useEffect 的同步版本。使用 useLayoutEffect 就可以達到我們上面說的,在同一次更新流程中解綁 interval 的目的。
useLayoutEffect里面的callback函數會在DOM更新完成后立即執行,但是會在瀏覽器進行任何繪制之前運行完成,阻塞了瀏覽器的繪制.
順序大概是這樣的:
useLayoutEffect: setRunning(false) => setLapse(0) => render(渲染) => (clearInterval =>執行effect)
方法2: 使用useReducer解決閉包問題
把 lapse 和 running 放在一起,變成了一個 state 對象,有點類似 Redux 的用法。在這里我們給 TICK action 上加了一個是否 running 的判斷,以此來避開了在 running 被設置為 false 之后多余的 lapse 改變。
那么這個實現跟我們使用 updateLapse 的方式有什么區別呢?
最大的區別是我們的 state 不來自於閉包,在之前的代碼中,我們在任何方法中獲取 lapse 和 running 都是通過閉包,而在這里,state 是作為參數傳入到 Reducer 中的,也就是不論何時我們調用了 dispatch,在 Reducer 中得到的 State 都是最新的,這就幫助我們避開了閉包的問題。
import React from 'react'
import ReactDOM from 'react-dom'
const buttonStyles = {
border: '1px solid #ccc',
background: '#fff',
fontSize: '2em',
padding: 15,
margin: 5,
width: 200,
}
const labelStyles = {
fontSize: '5em',
display: 'block',
}
const TICK = 'TICK'
const CLEAR = 'CLEAR'
const TOGGLE = 'TOGGLE'
function stateReducer(state, action) {
switch (action.type) {
case TOGGLE:
return {...state, running: !state.running}
case TICK:
if (state.running) {
return {...state, lapse: action.lapse}
}
return state
case CLEAR:
return {running: false, lapse: 0}
default:
return state
}
}
function Stopwatch() {
// const [lapse, setLapse] = React.useState(0)
// const [running, setRunning] = React.useState(false)
const [state, dispatch] = React.useReducer(stateReducer, {
lapse: 0,
running: false,
})
React.useEffect(
() => {
if (state.running) {
const startTime = Date.now() - state.lapse
const intervalId = setInterval(() => {
dispatch({
type: TICK,
lapse: Date.now() - startTime,
})
}, 0)
return () => clearInterval(intervalId)
}
},
[state.running],
)
function handleRunClick() {
dispatch({
type: TOGGLE,
})
}
function handleClearClick() {
// setRunning(false)
// setLapse(0)
dispatch({
type: CLEAR,
})
}
return (
<div>
<label style={labelStyles}>{state.lapse}ms</label>
<button onClick={handleRunClick} style={buttonStyles}>
{state.running ? 'Stop' : 'Start'}
</button>
<button onClick={handleClearClick} style={buttonStyles}>
Clear
</button>
</div>
)
}
function App() {
const [show, setShow] = React.useState(true)
return (
<div style={{textAlign: 'center'}}>
<label>
<input
checked={show}
type="checkbox"
onChange={e => setShow(e.target.checked)}
/>{' '}
Show stopwatch
</label>
{show ? <Stopwatch /> : null}
</div>
)
}
ReactDOM.render(<App />, document.getElementById('root'))