Hooks的官方定义
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
也就是React
版本 >= 16.8
就可以使用Hooks
的方式去编写React
代码,有一个前提条件,就是必须理解闭包,文中会提到,不理解的话会对组件的行为感到相当疑惑。
useState
在没有Hooks
之前,函数组件的问题在于只能“纯粹”的定义,没有内部状态,只能接受外部传入的外部数据,useState
解决的就是内部状态。
const App = () => {
const [num, setNum] = useState(0);
console.log(num);
return (
<div className="App">
<span>{num}</span>
<button onClick={() => setNum(num + 1)}>add</button>
</div>
);
};
// 页面会渲染出0
// 每次点击按钮时,页面会重新渲染并更新输出num+1后的最新值
复制代码
这段代码,相当于在Class
组件中,定义了state
,点击按钮后将num+1
,重新渲染页面并输出最新的num
值。和class
组件不同的是,函数组件每次重新渲染会将函数从头执行一遍,class
组件只会执行对应的声明钩子和render
函数(不了解Class
组件的可以先学习下,这里有一篇站内的 React食用一条龙服务,可以不用,但需要理解)
简易实现customUseState
通过一个"不太成熟"的例子,尝试去理解useState
的原理
const _states = []; // 用于存储state
let _index = 0; // 用于存初始化时的下标
const useCustomUseState = (initialValue) => {
const [_, reRender] = useState({}); // 这个不是重点,先不用管,当成能刷新组件的一个工具函数看
const currentIndex = _index; // 将当前的index保存
_index += 1; // 将i+1
_states[currentIndex] = _states[currentIndex] || initialValue;
// 判断当前下标元素是否有值,用与初始化state和后续函数重新渲染执行时更新_state
const setState = (newValue) => {
// 函数用于更新state值
_states[currentIndex] = newValue;
_render();
};
const _render = () => {
// 只要更新了值,就把index重置,因为app会重新渲染,再按顺序执行useCustomUseState函数
// 只有重置了才能按照执行顺序将_state中的值按顺序再次取出
_index = 0;
reRender({});
};
// 将当前传入的state和更新的state返回给使用者
return [_states[currentIndex], setState];
};
function App() {
const [num, setNum] = useCustomUseState(0);
const [aaa, setAaa] = useCustomUseState(111);
// 这里我们使用自己实现的useState
console.log(num);
console.log(aaa);
return (
<div>
{num}
<button onClick={() => setNum(num + 1)}>add one </button>
<hr />
{aaa}
<button onClick={() => setAaa(aaa + 1)}>add one </button>
</div>
);
}
复制代码
此时点击两个按钮,都会把对应的值给+1
,并且能重新渲染出最新的state
值。代码中const currentIndex = _index;
,这段赋值很关键,将当前执行的下标缓存起来,currentIndex
变量和即将return
出去的更新函数形成了一个闭包,这样即使_index
变化了,currentIndex
永远都是运行函数当时得到的_index
,而React
将例子中对应的_states
和_index
保存在组件节点上。
当理解了这个基础实现后,再去看useState
的感觉就通透很多了。
useState使用需要注意的有几点
- 不要尝试在当前的业务函数中,去获得更新后的值
useState
的执行顺序每次re-render
都要保证一样,也就是不能写在if else
里,否则React
会报错useState
不会像Class
组件那样自动合并state
,需要手工操作useState
可以接收一个函数去进行更新操作
1. 不要尝试在当前的业务函数中,去获得更新后的值
function App() {
const [num, setNum] = useCustomUseState(0);
const addNum = () => {
setNum(num + 1);
setTimeout(() => { console.log("num:" + num); }, 1000)
};
return <button onClick={addNum}>add one </button>
}
复制代码
这段代码点击按钮1s
后,输出的值永远都会是更新前的值。代码是同步执行的,但是setState
更新操作会重新执行App
这个函数(不用想的太复杂,把App看成普通函数),当在更新前,执行了setTimeout
函数,当前组件更新前的num
变量和setTimeout
传入的箭头函数又形成了一个闭包,相当于在更新后会存在两个"num"
变量,旧"num"
是0
,而新"num"
是1
,箭头函数拿到的是旧的"num"
变量,所以点击按钮1秒
后,输出的num
值会是0
。(又是闭包又是闭包又是闭包。。。。。。)
2. useState的执行顺序每次render都要保证一样,也就是不能写在if else
里,否则React会报错
const [num, setNum] = useState(0);
if(num === 0){
const [aaa, setAaa] = useState(111);
}
const [c, setC] = useState(0);
// React的报错如下
// React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render
复制代码
用我们自己实现的简易useState
来思考,如果执行的顺序不保证,在第一次执行函数时,调用了3
次useState
,返回值都正确,存数据的_states
数组此时长度是3
。可第二次执行函数调用useState
的次数是两
次,c
变量原本应该取_states[2]
的,但因为条件不满足少执行了一次useState
,导致执行到返回c
的函数时,拿到的是_states[1]
,就会导致刷新后的数据问题。
3.useState不会像Class组件那样自动合并state,需要手工操作
function App() {
const [person, setPerson] = useState({
name: "libai",
age: 89,
});
return (
<div>
{person.name}
{person.age}
<button onClick={() => setPerson({ age: 100 })}>changeAge</button>
</div>
);
}
复制代码
当点击按钮后,会发现页面中的libai
不见了,因为执行更新传入的对象中,并没有name
属性,传入的属性会覆盖掉旧值,不会自动合并,重新渲染后person
对象的值是{ age:100 }
,person.name
是undefined
,虽然是合法的,但不会输出任何东西。只要利用...
展开操作符或者Object.assign
,将旧对象的键值全部提取出来,再赋值新的值,新的值含有旧值的一切属性。
function App() {
const [person, setPerson] = useState({
name: "libai",
age: 89,
});
const changePerson = ()=>{
// setPerson({ ...person,age: 100 });
// 效果等价,则一使用
const newPerson = Object.assign({},person,{age:100});
setPerson(newPerson);
}
return (
<div>
{person.name}
{person.age}
<button onClick={changePerson}>changeAge</button>
</div>
);
}
复制代码
4. useState可以接收一个函数去进行更新操作
function App() {
const [person, setPerson] = useState({
name: "libai",
age: 89,
});
const changePerson = () => {
setPerson({ ...person, age: person.age + 10 });
setPerson({ ...person, age: person.age + 10 });
setPerson({ ...person, age: person.age + 10 });
setPerson({ ...person, age: person.age + 10 });
};
return (
<div>
{person.name}
{person.age}
<button onClick={changePerson}>changeAge</button>
</div>
);
}
复制代码
这段代码中,点击按钮后,代码看似会把89
执行4次+10
操作得到129
,但实际得到的结果是89
,导致的原因是闭包(又是tm
的闭包)。前面讲过,代码是同步执行,但更新操作是异步的,setPerson
执行了4
次,传入的参数对象person.age
获取到的都是89
这个值,person
对象和changePerson
函数形成了一个闭包,也就是4
个函数传入的对象的age
都是+10
后的99
。如果想解决这个问题,可以使用传入函数的方式去更新state
。将点击事件进行细微的改动
const changePerson = () => {
setPerson((person=> ({ ...person, age: person.age + 10 })));
setPerson((person=> ({ ...person, age: person.age + 10 })));
setPerson((person=> ({ ...person, age: person.age + 10 })));
setPerson((person=> ({ ...person, age: person.age + 10 })));
};
复制代码
函数的第一个参数是"当前"state
的值,这个"当前"并非指组件内state
的值(person变量
),就是实打实的state
最新的值。执行第一次setPerson
时,state
的age
为89
,更新了对象;第二次setPerson
时,state
的age
为99
,以此类推最终组件更新后获取的age
就是129
。理论上,优先使用这种更新方式,脑壳可以少疼点:)
useReducer
其实就是复杂版的useState
,通过某些限定操作,来达到“规范更新”的目的,useReducer
接受一个函数和一个初始值,得到读和写的api
,直接上代码
const initialValue = {
name: "",
age: 0,
};
const reducer = (state, { type, payload }) => {
switch (type) {
case "changeName":
return {
...state,
name: payload.name,
};
case "changeAge":
return {
...state,
age: payload.age,
};
default:
throw new Error("你tm传进来的type是什么东西");
}
};
function App() {
console.log("app running");
const [state, dispatch] = useReducer(reducer, initialValue);
const submitForm = (evt) => {
evt.preventDefault();
console.log(state);
};
return (
<form onSubmit={submitForm}>
<label htmlFor="nage">
姓名:
<input
id="name"
value={state.name}
onChange={(evt) =>
dispatch({
type: "changeName",
payload: { name: evt.target.value },
})
}
/>
</label>
<label htmlFor="age">
年龄:
<input
type={"number"}
id="age"
value={state.age}
onChange={(evt) =>
dispatch({
type: "changeAge",
payload: { age: evt.target.value },
})
}
/>
</label>
<button onClick={()=>dispatch({type:"have luncher"})}>test Error</button>
<button type={"submit"}>submit</button>
</form>
);
}
复制代码
useReducer
传入函数和初始值后,返回值和useState
类似,数组第一项是初始化好的state
,第二项则是用于更新state
的函数- 在名字输入框和年龄输入框修改时,实际上都触发了一次组件重新渲染,在
App
函数体中加了一句打印,每次输入时都会输出打印,而更新的操作是通过dispatch
函数去触发的(理解为setState
),接着就会去执行一开始我们定义好的reducer
函数并将state
值和dispatch
的入参二次传递传入,按照预先定义好的规则去更新当前state
。 - 当点击
submit
按钮时,控制台会输出当前state
对象的值 - 当传入错误的
type
时,比如代码中的testError
按钮的点击事件,就会抛出预先定义好的异常
简而言之,就是可以利用useReducer
去更加规范的管理复杂的内部状态变更(复杂版useState
)
useEffect
这个hooks
会在页面渲染后执行相对应的回调,可以实现Class
组件中,componentDidMount
,componentDidUpdate
,componentWillUnmout
这三个生命周期的作用。
它接受一个函数和一个可选参数(数组)作为参数,一个函数中存在多个useEffect,那么会按照出现的顺序依次去执行。
模拟componentDidMount
作用
第二个参数,传入一个空数组表示无任何依赖,那么在App被执行重新渲染时,也不会再次执行函数中的内容
function App() {
const [num, setNum] = useState(0);
useEffect(() => {
console.log("effect running");
const divEle = document.querySelector("#hook");
divEle.style.color = "red";
}, []);
return (
<>
{num}
<button onClick={() => setNum(num + 1)}>re-render</button>
<div id="hook">hi,hooks</div>
</>
);
}
复制代码
- 页面渲染后,会获取
id
为hook
的div
并将其style
的color
属性改为red
,因为能拿到dom
元素,也能作证它的执行时机时在页面渲染后,没有渲染的话拿个寂寞么: ) - 点击
re-render
按钮后,会更新num
的值并再次执行App
函数,观察控制台"effect running"
只会打印一次,无论点击多少次re-render
按钮,都不会再执行useEffect
的回调函数
模拟componentDidUpdate
作用
第二个参数,传入一个带有依赖值的数组,用来表示当依赖数组中的某一项发生了变更后,再次执行回调函数,实例代码中,传入了num作为依赖项
function App() {
const [num, setNum] = useState(0);
useEffect(() => {
console.log("effect running");
}, [num]);
return (
<>
{num}
<button onClick={() => setNum(num + 1)}>re-render</button>
</>
);
}
复制代码
- 页面渲染后,会获取
id
为hook
的div
并将其style
的color
属性改为red
,因为能拿到dom
元素,也能作证它的执行时机时在页面渲染后,没有渲染的话拿个寂寞么: ) - 除了页面渲染后会打印一次以外,每次点击
re-render
按钮,会更新num
的值,而因为传入了num作为依赖项,所以回调函数被再次执行了
当依赖项不传时,相当于任何state被更新后,都会执行回调函数
useEffect(() => {
console.log("effect running");
});
复制代码
按照当前预期,传入依赖项和不传的表现一致,点击按钮都会再打印一次log
模拟componentWillUnmount
作用
function App() {
const [childVisible, setChildVisible] = useState(true);
return (
<>
<button onClick={() => setChildVisible(!childVisible)}>toggle</button>
{childVisible && <ChildTest />}
</>
);
}
function ChildTest() {
const [num, setNum] = useState(0);
useEffect(() => {
console.log("effect running");
return () => {
console.log("component unmount");
};
}, [num]);
return (
<>
{num}
<button onClick={() => setNum(num + 1)}>re-render</button>
</>
);
}
复制代码
useEffect
回调函数中,return
的函数,会在组件被卸载时执行,上面的代码中,每次点击按钮,都会卸载(重新渲染也可以认为是另一种形式的卸载)页面再重新渲染,点击toggle
按钮,当childVisible
为false
时不满足条件则不会渲染ChildTest
组件,那么卸载也会执行return
的函数
useLayoutEffect
这个hooks
的执行时机在浏览器渲染前,也就是在浏览器能看到div
之前去执行
graph TD
useLayoutEffect --> 浏览器执行渲染 --> useEffect
function App() {
useEffect(() => {
console.log("useeffect");
}, []);
useLayoutEffect(() => {
console.log("useLayoutEffect");
document.querySelector("#text").innerHTML = "一二三四五六七八九十";
}, []);
return (
<>
<div id="text">123456789</div>
</>
);
}
复制代码
这段代码,在App
被渲染时,先输出了console.log("useLayoutEffect");
,接着把页面id
为text
的文档内容改为一二三四五六七八九十
,此时文档对象(document
)已在内存中,只是没有渲染到页面视图上,接着再执行useEffect的输出console.log("useeffect");
推荐始终使用useEffect,除非真的有要在页面渲染前改变layout的需求
useContext
context用于表示上下文,全局变量是全局的上下文,上下文是局部的全局变量
Context
我已经写过博客便不重讲了,可以通过这个站内链接食用 React中使用Context的3种方式 安全无毒:)
做个类比来说就是用提供的容器去指定提供范围,为范围内的所有组件提供 食材,餐具,燃气,锅碗瓢盆,九阴真经,易经 等 ,扣门的话可以只提供锅碗瓢盆,食材让消费者自己买,烹饪让消费者自己学。被包裹的任意组件都可以共享使用容器提供的事物(对象)和行为(方法)。
需要注意,如果修改共享的值,不会触发刷新(不是响应式)
Context 可以代替 Redux
useRef
如果需要一个值在组件不断render
时,保持唯一不变,可以使用useRef
,可以用于存储Dom
对象,也可以存储任意对象,这个存储的值不会因组件更新而改变,返回值是{current:x}
,对象的current
属性存放着目标值,为什么需要包一层current
?因为只有对象才能保持地址不变来实现永远访问的都是同一个对象。
引用对象的示例
function App() {
const crf = React.createRef();
const urf = useRef();
const showAllRef = () => {
console.log(crf.current);
console.log(urf.current);
};
return (
<>
<div ref={crf}>createRef</div>
<div ref={urf}>useRef</div>
<button onClick={showAllRef}>showAllRef</button>
</>
);
}
复制代码
代码中用createRef
和useRef
来分别引用dom
元素,点击showAllRef
按钮时,都会将对应dom
实例打印在控制台,此时他们效果是等价的,但是在函数组件当中使用createRef有个问题,因为每次组件被刷新时,相当与又执行了一遍App
函数,而urf
变量会被createRef
函数的返回值再次赋值,如果用来存储可变对象则不要使用creatRef
,这点需要注意。
用useRef
来维持状态且不触发刷新,示例中会统计点击add
按钮的次数,可以当作一个组件存活期间的组件内的全局对象来使用(类比只存在这个组件中的window
对象)
function App() {
let count = useRef(0);
const [num, setNum] = useState(100);
useEffect(() => {
if (num > 100) {
count.current += 1;
}
}, [num]);
return (
<>
{num}
<button onClick={() => setNum(num + 100)}>add 100</button>
<button onClick={() => console.log(count.current)}>showCount</button>
</>
);
}
复制代码
forwardRef 讲到ref,就得提一下forwardRef
函数组件中,props
无法传递ref引用,强行传递会抛出警告
react-dom.development.js:67 Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
复制代码
这时候就需要通过React.forwardRef
const App = () => {
const buttonRef = useRef(null);
const clickChild = () => {
buttonRef.current.click();
};
return (
<>
<Child ref={buttonRef} />
<button onClick={clickChild}>点击我会调用子组件的click事件</button>
</>
);
};
const Child = React.forwardRef((props, ref) => {
return (
<div>
<button ref={ref} onClick={() => console.log("click")}>
会被父组件引用的button
</button>
</div>
);
});
复制代码
示例中不管点击App
组件中的按钮还是Child
组件的按钮,都是相当于点击Child
组件的button
,forwardRef
会返回一个能接收ref
的组件,一般返回组件的函数称为高阶组件。然后进行ref
的转发,Child
组件就能从第二个参数获得这个ref
的引用,并用于button
的示例对象,此时buttonRef.current === 子组件的button
实例对象
useImperativeHandle
useImperativeHandle
可以让你在使用ref
时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle
应当与forwardRef
一起使用。
意思就是ref
返回给父组件是啥玩意,完全自己决定。正常情况下,ref.current
是目标dom
实例对象,下面使用useImperativeHandle
自定义了ref
的返回值,父组件需要通过ref.current.amd
获取dom
的引用,并且还返回了一个额外的msg
字段
const App = () => {
const buttonRef = useRef(null);
const clickChild = () => {
console.log(buttonRef.current);
};
return (
<>
<Child ref={buttonRef} />
<button onClick={clickChild}>点击我会打印 子组件的ref对象</button>
</>
);
};
const Child = React.forwardRef((props, ref) => {
const buttonRef = createRef(null);
useImperativeHandle(ref, () => {
return {
msg: "你是啥玩意,不允许引用,算了,还是给你吧,用amd字段读吧",
amd: buttonRef.current,
};
});
return (
<div>
<button ref={buttonRef} onClick={() => console.log("click")}>
会被父组件引用的button
</button>
</div>
);
});
复制代码
点击打印按钮,可以在控制台看到{msg:"xxx",amd:button Instance}
React.memo && React.useMemo && useCallback
要理解useMemo
和useCallback
,就要先理解React.mome
下面实例,点击changeState
按钮,虽然传入Child
的props
没有改变,但依然被触发更新了,如何做到只有传入的依赖的变化才更新来减少不必要的更新呢
function App() {
const [childProps] = useState(0);
const [text, setText] = useState("text");
return (
<>
<div>{text}</div>
<button onClick={() => setText(text + "text")}>changeState</button>
<hr />
<Child childProps={childProps} />
</>
);
}
const Child = (props) => {
console.log("我运行了");
return <div>child:props.m:{props.childProps}</div>;
};
复制代码
被React.memo
包裹后的高阶组件,默认会对props
进行浅层比较,当app
进行render
时,会对比两次props
,如果相同则不会再更新,可以传入第二个参数进行控制对比结果,可以看下自定义对比函数的Typescript
定义,接受两个参数,旧的props和新的props,返回boolean
决定是否更新组件
propsAreEqual?: (prevProps: Readonly<PropsWithChildren<P>>, nextProps: Readonly<PropsWithChildren<P>>) => boolean
复制代码
函数返回true
则不再进行更新,返回false
则更新
const Child = (props) => {
console.log("我运行了");
return <div>child:props.m:{props.childProps}</div>;
};
function App() {
const [childProps] = useState(0);
const [text, setText] = useState("text");
return (
<>
<div>{text}</div>
<button onClick={() => setText(text + "text")}>changeState</button>
<hr />
<Child childProps={childProps} />
</>
);
}
const Child = React.memo((props) => {
console.log("我运行了");
return <div>child:props.m:{props.childProps}</div>;
});
复制代码
但是这个方案有个Bug
,如果props
传入了一个函数,就一键破功,原因是父组件更新时,函数又被重新赋值,虽然功能一样,但是两个不同的函数,导致子组件又被重新渲染,这时候就需要useMemo
登场,它的使用类似Vue
中的计算属性Computer
,会把值缓存起来,只有依赖变了才会重新计算,useMemo
第一个参数接受一个函数,返回值就是缓存结果,第二个参数是依赖数组,只有依赖项变化才会重新计算。
function App() {
const [childProps] = useState(0);
const [text, setText] = useState("text");
const fn = useMemo(() => {
return () => {};
}, [childProps]);
return (
<>
<div>{text}</div>
<button onClick={() => setText(text + "text")}>changeState</button>
<hr />
<Child childProps={childProps} setText={fn} />
</>
);
}
复制代码
现在点击按钮父组件更新时,子组件也不会被更新,因为fn
被缓存,指向同一个函数地址,useMemo
不单单缓存函数,任何数据都可以缓存,如果觉得缓存函数写法有点绕,需要写一个函数再返回一个函数,于是有了语法糖useCallback
,不需要在函数里返回函数,只需要传入函数即可,对比useMemo
相当于少写了一层
// react实现了一个语法糖
function App() {
const [childProps] = useState(0);
const [text, setText] = useState("text");
const fn = useCallback(() => {}, [childProps]);
return (
<>
<div>{text}</div>
<button onClick={() => setText(text + "text")}>changeState</button>
<hr />
<Child childProps={childProps} setText={fn} />
</>
);
}
复制代码
useCallback(fn, deps)
相当于useMemo(() => fn, deps)
。
自定义Hooks
最后说说自定义Hooks
,其实就是和拼积木一样,如何将状态逻辑抽离封装和使用,完全取决是玩家自己,属于Hooks
的灵魂。
const useUserList = () => {
const [list] = useState([]);
const [loading] = useState(true);
useEffect(() => {
// 假设这里发起了一个Http请求一个列表
setTimeout(() => {
setList([1, 2, 3]);
setLoading(false);
}, 2000);
}, []);
return [list, loading, setList, setLoading];
};
const App = () => {
const [userList, loading] = useUserList();
return (
<>
{userList.length
? userList.map((user) => <span key={user}>用户:{user}</span>)
: null}
{loading && <div>加载中</div>}
</>
);
};
复制代码
- 这里封装了一个自定义
hook
取名为useUserList
,React
有规定hooks
必须以use
开头命名,这里只是为了展示demo功能,实际上完全可以新建一个hooks
的文件夹,针对每个功能的hook
进行封装然后导出,在需要使用的组件中,我们只需要引入这个hook
函数,并且调用一下就能得到我们想要的数据和行为,和写class
组件相比,状态与组件进行了拆分,代码也能得到复用。 demo
模拟了一个http
请求,两秒后返回数据,只所以能获取数据并更新组件,是因为hook
中的setState
实际是在组件中执行,写在组件外,执行在组件里。- 值得一提的是,
hook
的使用只能在函数组件或者use开头
的函数当中,否则React
会报错。
看到这里,相信对React Hooks
的特性已经"入门"了,感谢收看:)