我们用ReactHooks
也有一年时间了,到了需要用它更好地表达这个阶段。
我做了几个例子,希望对大家有些帮助。
1. 显示当前时间
这个例子没有什么特别,但覆盖了80%的真实场景。
const App = () => {
const [time, setTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div>{formatTime(time)}</div>
)
};
涉及到的点有:
- 使用 useState 定义状态
-
使用 useEffect 定义副作用
- 这个Effect无依赖
- 在effect中可同时定义了 初始化 和 清理 操作。
这是useEffect
相对于生命周期函数componentDidMount
和componentWillUnmount
的好处:让初始化和清理这一对操作在一起。
和React无关,这个例子还用到String#padStart
,用来格式化时间,让数字显示保持两位。你能想到的最简实现是什么? 我想到一个:
('0' + v).substr(-2);
2. effect和依赖
这个示例探索effect 执行 和 清理 的 时机。
点击 Add 可以添加一项;
点击 Remove 可以移除一项。
打开console
可以看相应的日志,其中 Item中相关的代码如下:
const Item = ({ task, onDelete }) => {
useEffect(() => {
console.log('enter', task.id);
return () => {
console.log('leave', task.id)
};
}, [task]);
...
可以看到 添加 时,会执行 enter
,移除 时会执行 leave
。
如果熟悉 componentDidMount
和 componentWillUnmount
,就会很自然地把 useEffect
和这两个生命周期等同起来,毕竟两者现象一致,特别是当依赖设置为空[]
时,再加上Hooks是后来才出现的,就会想当然地认为useEffect(..., [])
是上面两个生命周期的语法糖。
不过这种看法是不对的, 它会影响我们使用useEffect
去有效表达,因为我们还在使用生命周期去考虑,而不是从effect的角度去考虑。
使用Effect
表达一个重要的点是:这个Effect依赖什么? 比如Effect中使用了task
,所以就诚实地告诉React,依赖是[task]
,而不要一股脑地都写成[]
。
3. effect和closure
我对上一个示例做了些改动,主要是想表达:每次渲染,Effect都有独立的版本
相关代码如下
const Item = ({ task, onDelete, onFinish }) => {
const stamp = Date.now();
useEffect(() => {
console.log('enter', task.id, stamp);
return () => {
console.log('leave', task.id, stamp);
};
}, [task]);
Item是一个组件,其实是个function
useEffect传递的也是一个function
,所以effect也是一个function
。
每次渲染时,都会执行Item函数,然后调用useEffect
构造一个新的Effect函数。得益于js中function具有closure特性,它们都拥有 独立的数据。
添加3个Item, console输出:
enter 1 1582464544868
enter 2 1582464545132
enter 3 1582464545358
其中1,2,3是Item的序号,后面的是渲染时的时间戳,后面会用到。
点击中间的finish
按扭,输出:
leave 2 1582464545132
enter 2 1582464703578
我们看到leave时,引用的是刚才渲染版本的stamp,然后马上enter,引用的是新版本的stamp。
再点一下中间的finish
按扭。
输出:
leave 2 1582464703578 // 上一个版本的stamp
enter 2 1582464921857 // 当前版本的stamp
注意到更新时, 会执行leave和enter,它们来自不同版本的Effect。
const Item = ({ task, onDelete, onFinish }) => {
const stamp = Date.now();
useEffect(() => { // 每次渲染都是新的function
console.log('enter', task.id, stamp);
return () => {
console.log('leave', task.id, stamp);
};
}, [task]);
4. think effects
我们再看一个示例,来体会刚才的点。
例子中有两个按扭:
其关键代码为:
class CounterA extends React.Component {
state = {
times: 0
};
handleAdd = () => {
this.setState({ times: this.state.times + 1 });
}
handleLog = () => {
setTimeout(() => {
console.log(`times: ${this.state.times}`);
}, 3000);
}
}
依次点击Add Log Add Log Add Log
会输出:
times: 3
times: 3
times: 3
而使用hooks实现
const CounterB = () => {
const [times, setTimes] = useState(0);
const handleAdd = () => {
setTimes(times + 1);
};
const handleLog = () => {
setTimeout(() => {
console.log(`times: ${times}`);
}, 3000);
};
依次点击Add Log Add Log Add Log
会输出:
times: 1
times: 2
times: 3
这有点类似于JS经典迷题:
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
}, 100);
}
5 useRef
如何让function版本的组件拥有class版本的效果呢? 可以使用useRef
。
const Counter = () => {
const [times, setTimes] = useState(0);
const timesRef = useRef(times); // 一次组件生命周期只拥有一个实例
useEffect(() => {
timesRef.current = times; // 使用effect更新ref的数据
}); // 这里也可以加上times依赖
const handleAdd = () => {
setTimes(times + 1);
};
const handleLog = () => {
setTimeout(() => {
console.log(`times: ${timesRef.current}`);
}, 3000);
};
有了上面的基础,我们就可以解释实际编程中会碰到的关于Effect的坑,并探索最佳实践。
6 失效的计数器
让我们又从计数器开始:
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
console.log(count);
setCount(count + 1);
}, 1000);
}, []);
return (
<div>{{count}}</div>
);
};
很遗憾 这个计数器不能正常工作,如果理解了刚才的点,那么原因显而易见。
因为setInterval
中拿到的count,总是自己的那个版本的count,即0。 // << -- 理解这点是个关键。
7. useEffect的依赖
那如何解决呢?有一种方法,只是针对当前示例不合理,但对于真实的场景会很合适。即管理好useEffect的依赖。
编写Effect时需考虑其依赖,就像编写函数时需仔细考虑其签名(名称、参数返回值)。
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // 诚实地告诉useEffect依赖,就没问题啦!
8. get last state
上述实现虽然现象正常,但每次执行effect时,都会重新初始化计数器,这一点不大合理,那有什么解决方法呢? setCount其实可以传递一个函数,在里面可以拿到上一次的state。
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
//setCount(count + 1);
setCount(last => last + 1); // 可拿到上次state
}, 1000);
}, []);
9. muti deps
继续刚才的示例,添加一个step状态,用于控制每次增加的值。
这时候callback方式就不适用了,因为它每次只能拿到一个状态值。不过useEffect可拥有多个依赖。
const App = () => {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const timer = setInterval(() => {
setCount(last => last + step);
}, 1000);
return () => clearTimeout(timer);
}, [count, step]); // 多个依赖
10. useReducer
上一示例的实现方式并不优雅,每次都会安装新的计数器(setInterval),那如何取得多个上一次的状态值呢?
可以使用useReducer
const App = () => {
// React保证dispatch在生命周期内唯一
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
const { count } = state;
useEffect(() => {
setInterval(() => {
dispatch({ type: 'tick' });
}, 1000)
}, []);
const handleChange = e => {
const step = +e.target.value || 0;
dispatch({ type: 'step', payload: step });
};
在reducer中,我们就可以拿到所需要的state:
function reducer(state, { type, payload }) {
if (type === 'tick') {
state = { ...state, count: state.count + state.step };
}
if (type === 'step') {
state = { ...state, step: payload }
}
return state
}
11. inner reducer
可以将reducer放在组件内部,这里有点神奇:
const App = () => {
const [step, setState] = useState(1);
// 虽然每次reducer都是新的版本,
// 但是React能让其正确工作!!
const reducer = (count, { type }) => {
if (type === 'tick') {
return count + step;
}
return count;
};
const [count, dispatch] = useReducer(reducer, 0);
useEffect(() => {
setInterval(() => {
dispatch({ type: 'tick' });
}, 1000)
}, []);
const handleChange = e => {
const step = +e.target.value || 0;
setState(step);
};
12. load data
接下来几个示例探索load data的最佳实践
const App = () => {
const [data, setData] = useState(null);
useEffect(() => {
const load = async() => {
const res = await loadData({ id: 123 });
setData(res.data);
};
load();
}, []);
最简单的实现,我们日常的业务实现多数是这种。
上述实现不考虑依赖,如果取数依赖了props,则变化后并不会重新请求数据,
作为业务实现合理,如果是可复用组件,则需要考虑更新问题。
方式是仔细考虑effect的依赖,在几次实践后,就会真正地think effects的方式去创建组件。
const Item = ({ id }) => {
const [data, setData] = useState(null);
useEffect(() => {
const load = async() => {
const res = await loadData({ id });
setData(res.data);
};
load();
}, [id]); // 添加id作为依赖。
13 重用函数
如果要重用loadData
怎么办呢?
const Item = ({ id }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
// 需要重用这个load
const load = async () => {
setLoading(true);
const stamp = Date.now();
const res = await loadData({ id, stamp });
setLoading(false);
setData(res.data);
};
useEffect(() => {
load();
}, [id]); // 依赖是id?
const handleReload = () => {
load();
};
14 function as deps
上例useEffect中,依赖id合理吗?
我觉得实现虽然没问题。可是表达上不够好。
load虽然现在在旁边,但也有可能在较远的地方,那effect就不大可能知道load
函数到底依赖什么。
effect实际上依赖的是load这个函数,但根据我们前面的分析,每次渲染都有不同的load, 所以不能直接用当作依赖。
这时候,useCallback
就派上用场了。
const Item = ({ id }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
const stamp = Date.now();
const res = await loadData({ id, stamp });
setLoading(false);
setData(res.data);
}, [id]); // callback知道自己依赖什么
useEffect(() => {
load();
}, [load]); // function as deps
const handleReload = () => {
load();
};
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。