背景
工作结束,又到了愉快的总结时间。
vue3 beta 版本发布之后,大家都说借鉴了 React Hooks,一直使用 vue 开发项目的我也来跟风学习下。
React 官方文档看起来实在费劲,自己总结了常见的几个 Hook用法,希望能帮助大家理解。
这篇文章主要整理一下 React 中的几个常见 Hook:
- 状态 useState
- 副作用 useEffect
- useLayoutEffect
- 上下文 useContext
- useReducer
- 记忆 useMemo
- 回调 useCallback
- 引用 useRef
- 自定义 Hook
useState
用法
- 创建初始值 initialState
- [x, setX] = React.useState(initialState);
- x 是初始值,setN是操作 x 的函数
import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");
function App() {
// 0是n的默认值,setN是操作n的函数
const [n, setN] = React.useState(0);
return (
<div className="App">
<p>{n}</p>
<p>
<button onClick={() => setN(n + 1)}>+1</button>
</p>
</div>
);
}
ReactDOM.render(<App />, rootElement);
不能局部更新
如果 state 是一个对象,不能部分更新 setState,必须将对象所有属性一起传递。
useReducer
- 创建初始值 initialState
- 创建所有操作 reducer(state, action)
- 传给 useReducer,得到读和写 API
- 调用 写({type: '操作类型'})
import React, { useState, useReducer } from "react";
import ReactDOM from "react-dom";
// 1. 创建初始值 initialState
const initial = {
n: 0
};
// 2. 创建所有操作 reducer(state, action)
const reducer = (state, action) => {
if (action.type === "add") {
return { n: state.n + action.number };
} else if (action.type === "multi") {
return { n: state.n * 2 };
} else {
throw new Error("unknown type");
}
};
function App() {
// 3.传给 useReducer,得到读 state 和写 dispatch
const [state, dispatch] = useReducer(reducer, initial);
const { n } = state;
const onClick = () => {
// 4. 调用 写({type: '操作类型'})
dispatch({ type: "add", number: 1 });
};
const onClick2 = () => {
dispatch({ type: "multi", number: 2 });
};
return (
<div className="App">
<h1>n: {n}</h1>
<button onClick={onClick}>+1</button>
<button onClick={onClick2}>x2</button>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
总的来说,useReducer相当于useState的复杂版。
好处是能够践行Flux/Redux的思想。
那么useState 和 useReducer 应该如何选择呢?
事不过三原则。如果同一个操作重复了3次以上,就使用useReducer。
useContext
使用方法
- 使用 C = React.createContext(initial); 创建上下文
- 使用 <C.Provider> 圈定作用域
- 在作用域内使用 useContext(C) 来使用上下文
import React from "react";
import ReactDOM from "react-dom";
const rootElement = document.getElementById("root");
const C = React.createContext(null);
function App() {
const [theme, setTheme] = React.useState("red");
return (
// C.Provider 表示 value 这个的作用域从这里开始
<C.Provider value={{theme, setTheme}}>
<div className={`App ${theme}`}>
<p>{theme}</p>
<ChildA />
</div>
</C.Provider>
);
}
function ChildA() {
const { setTheme } = React.useContext(themeContext);
return (
<div>
<button onClick={() => setTheme("red")}>red</button>
</div>
);
}
ReactDOM.render(<App />, rootElement);
useContext 翻译为使用上下文,那什么是上下文呢?
上下文
上下文就是运行一段程序,需要的所有变量。
比如我们都知道的全局变量,就是全局的上下文。因为全局代码都需要这个变量。
上下文也是局部的全局变量,在一段代码内,都需要这个变量。
注意事项
useContext 的更新机制不是响应式的,而是重新渲染,从根组件到子组件逐渐通知的过程。
你在一个模块将 C 里的值变化,另一个模块不会感知到这个变化。
当然,这个变化对于我们写代码基本没有影响。
useEffect
effect 翻译为「副作用」,什么是副作用呢?
在函数式编程中,对环境的改变即为副作用。比如修改 document.title。
但我们不一定非要把副作用放在 useEffect 里。
实际上对于没有接触过函数式编程的前端工程,叫做 afterRender 更好,意思是每次 render 后运行。
用途
可以代替三个钩子:
- componentDidMount 出生,第一次渲染,[] 作 useEffect 第二个参数。
- componentDidUpdate 更新,第2、3...次渲染,第二个参数指定依赖
- componentWillUnmount 死亡,销毁前渲染,通过 return
以上三种用途可同时存在。
如果同时存在多个 useEffect,会按照出现顺序依次执行。
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
function App() {
const [n, setN] = useState(0);
const onClick = () => {
setN(i => i+1)
}
// 1. [] 里面的变量变化时执行 => 只执行一次,不会再次执行
useEffect(() => {
console.log('第一次渲染后执行这句话')
}, []);
// 2. n 变化时执行
useEffect(() => {
console.log('n 变化了')
}, [n]);
// 3. 销毁前渲染,通过 return
useEffect(() => {
const timer = setInterval(() => {
console.log('hi')
}, 1000)
}, return () => {
window.clearInterval(id);
});
return (
<div className="App">
<h1>n: {n}</h1>
<button onClick={onClick}>+1</button>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
useLayoutEffect
layout effect 翻译为「布局副作用」。
useEffect 在浏览器渲染完成后执行,useLayoutEffect 在浏览器渲染前执行。 useLayoutEffect 总是比 useEffect 先执行。
经验
为了用户体验,优先使用 useEffect。
因为 useLayoutEffect 在渲染前执行,和 useEffect 相比有更长时间的白屏。
当 useEffect 不能满足你的需求时,再使用 useLayoutEffect。
useLayoutEffect 里的任务最好影响了 Layout(页面布局)。
useMemo
React.memo
要理解 React.useMemo,需要先理解 React.memo。
React 默认有多余的render,比如说 n 变了,但是依赖 m 的组件也刷新了。
代码中的组件,比如 Child 用 React.memo(Child) 代替。
就可以做到 props 不变,不会再执行一个函数组件。
import React from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function App() {
const [n, setN] = React.useState(0);
const [m, setM] = React.useState(0);
const onClick = () => {
setN(n + 1);
};
return (
<div className="App">
<div>
<button onClick={onClick}>update n {n}</button>
</div>
<Child data={m}/>
</div>
);
}
const Child = React.memo((props) => {
console.log("child 执行了");
console.log('假设这里有大量代码')
return <div>child: {props.data}</div>;
});
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
bug
在 Child 添加了监听函数之后,监听函数每次都会执行。
function App() {
const [n, setN] = React.useState(0);
const [m, setM] = React.useState(0);
const onClick = () => {
setN(n + 1);
};
const onClickChild = () => {
console.log(m);
};
return (
<div className="App">
<div>
<button onClick={onClick}>update n {n}</button>
</div>
<!-- {/* Child2 居然又执行了 */}-->
<Child data={m} onClick={onClickChild} />
</div>
);
}
const Child = React.memo((props) => {
console.log("child 执行了");
console.log('假设这里有大量代码')
return <div>child: {props.data}</div>;
});
因为 App 运行时会生成新的函数,新旧函数虽然功能意义,但是地址不一样。
怎么办?用useMemo
useMemo
第一个参数是 () => value,第二个参数是依赖[m,n]。
只有当依赖变化,才会计算新的value。 如果依赖不变,那么就重用之前的value。
(和 vue2 的 computed 功能相同哇)
注意
如果你的value是个函数,就要写成 useMemo(() =>(x) => console.log(x))。
这是一个很难返回的函数,于是就有了useCallback。
useCallback
useCallback 只是一个语法糖。
useCallback(x => log(x), [m]) 等价于 useMemo(() =>(x) => console.log(x), [m])
useRef
目的
如果你需要一个值,在组件不断 render 时保持不变。使用 useRef。
但是页面不会自动更新,需要手动刷新。
function App() {
// 初始化
const count = useRef(0); // {current: 0}
const onClick = () => {
// 读取:count.current
// 为什么需要 current?
// 为了保证两次 useRef 是同一个值。不改变 useRef 对象的地址,只改变 count.current 的值
count.current += 1;
console.log(count.current)
}
return (
<div className="App">
// 页面不会自动更新,没有 re-render
<h1>n: {count.current}</h1>
<button onClick={onClick}>+1</button>
</div>
);
}
Vue3 的 ref
import { ref, watchEffect } from 'vue'
// 初始化:const count = ref(0)
const count = ref(0)
function increment() {
// 读取:count.value
count.value++
}
const renderContext = {
count,
increment
}
// 不同点:当count.value 变化时,vue3 会自动 render, 页面变化
watchEffect(() => {
renderTemplate(
`<button @click="increment">{{ count }}</button>`,
renderContext
)
})
forwardRef
如果一个函数组件,想要接受其他组件传递的 ref 参数。 必须使用 React.forwardRef 包裹起来。
因为 props 不包含 ref
function App() {
const count = useRef(0); // {current: 0}
return (
<div className="App">
<Button3 ref={buttonRef}>按钮</Button3>
</div>
);
}
const Button3 = React.forwardRef((props, ref) => {
return <button ref={ref}></button>
})
最牛逼的功能:自定义 Hook
封装数据操作
不管用的什么 hook,只需要将增删改查暴露出去。 使用时直接调用,不用关心内部逻辑。
一个例子
import React, { useRef, useState, useEffect } from "react";
import ReactDOM from "react-dom";
// 自定义 hooks useList
const useList = () => {
const [list, setList] = useState(null);
useEffect(() => {
ajax("/list").then(list => {
setList(list);
});
}, []); // [] 确保只在第一次运行
// 暴露出读写接口
return {
list: list,
setList: setList
};
};
function App() {
// 使用 hooks index.js
const { list, setList } = useList();
return (
<div className="App">
<h1>List</h1>
{list ? (
<ol>
{list.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ol>
) : (
"加载中..."
)}
</div>
);
}
// mock
function ajax() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([
{ id: 1, name: "Frank" },
{ id: 2, name: "Jack" }
]);
}, 2000);
});
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Stale Closure
译为过时的闭包。 在使用 useEffect 时,需要注意过时的闭包问题。 具体问题可以点击这里查看,我在这简单描述下。
function createIncrement(i) {
// 一个函数用到了它外面的变量,就是闭包
let value = 0;
function increment() {
value += i;
console.log(value);
const message = `Current value is ${value}`;
return function logValue() {
console.log(message);
};
}
return increment;
}
const inc = createIncrement(1);
const log = inc(); // logs 1 (第一次执行,得到message1)
inc(); // logs 2 (第二次执行,得到message2)
inc(); // logs 3 (第三次执行,得到message3)
log(); // logs "Current value is 1" 因为这里保存了一个过时的 log
实际上,第一次执行 inc(),得到 message1;第二次,得到 message... log 保存的是 message,这就是过时的闭包。
如果我们想得到的是 3,应该怎么做?
- 使用最新的 log 函数
const inc = createIncrement(1);
inc(); // logs 1
inc(); // logs 2
const latestLog = inc(); // logs 3
// Works!
latestLog(); // logs "Current value is 3"
- 用最新的 value 第二种方法是直接logValue()利用value。
让我们将行const message = ...;移到logValue()函数主体中:
function createIncrementFixed(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
return function logValue() {
//******************
const message = `Current value is ${value}`;
console.log(message);
//******************
};
}
return increment;
}
const inc = createIncrementFixed(1);
const log = inc(); // logs 1
inc(); // logs 2
// Works!
log(); // logs "Current value is 3"
useEffect
让我们研究一下使用 useEffect() 时过时关闭的情况。
钩子在组件内部 useEffect() 记录以下值count:
function WatchCount() {
const [count, setCount] = useState(0);
useEffect(function() {
setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
}, []);
return (
<div>
{count}
<button onClick={() => setCount(count + 1) }> Increase </button>
</div>
);
}
然后单击几次增加按钮。然后看一下控制台,每隔2秒钟就会出现一次Count is: 0。
为什么会发生?
第一次渲染时,闭包log()将count变量捕获为0。 后来,即使count增加,log()仍然从最初的渲染使用count:0。log()是一个过时的闭包。
解决方案是让useEffect()闭包log()依赖count:
useEffect(function() {
setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
// 正确设置依赖 项count 后,请useEffect()在count更改后立即更新闭包。
}, [count]);