实战篇,10小时学会React
前言
为什么写这篇文章?在工作多年后,React仿佛已经融入了自己的血液当中,很多时候往往都是下意识的操作。那这些实战性的操作,仿佛都是神来之笔。现在回想起来,大概是因为自己没有关注基础部分,虽然很久之前算是系统的学过一次,也对学习过程进行了输出,并且那份笔记也在工作中帮我回顾了很多。但现在我想重新整理知识脉络,结合实战方向,利用主流的库,写一篇文章。和以往大而全的文档外,我想更细节的对某些知识点进行整理。如果说官方文档是一个说明书。那么,这个文档就是,几张细节丰富的微观照片。
一、前端工作逻辑
在学习之前,我们回顾一下一个前端开发,都需要做哪些工作,如果有一个前端框架,我们需要它能做点什么?
- 根据需求文档,整理出有几个大功能,每个大功能下,有几个小功能。
- 产品经理会将需求转换成原型图。前端根据原型图写页面。
- 前端和后端确定字段,约定接口,调用接口。
- 根据业务,处理逻辑,进行状态管理。
- 业务完成,进行单元测试。
以上就是大部分前端需要做的工作。我们可以根据以上来看我们需要哪些工具,帮我们完成任务。
第一点可以看出,我们需要一个前端框架,帮助我们对dom进行控制和管理,这里react就可以完成。但是我们需要路由管理进行路由跳转,这里需要react-router-dom
第二点可以看出,我们需要对页面增加样式,这里jsx语法是一大助力,样式推荐sass或less,当然,可可能更高还需要一些ui组件比如说antD,或者数据可视化,,目前常用的是echarts。
第三点可以看出,我们需要一个接口请求方式,这里推荐fetch,如果你有模拟数据的需求,需要学会使用mock。
第四点可以看出,我们需要根据需求的复杂度选择使用state,还是redux。
第五点不是所有前端都需要去做的,尤其是小型项目,完全不需要测试。如果需要jest是个不错的选择。
当然有同学会说还有其他的工作,但是上面这些任务能占据日常工作中的90%。
我们发现,学习使用react开发并不是只需要学会react,虽然前端框架的存在帮我们省去了使用webpack等打包的工作,但是完成一个项目,需要使用很多库来协助完成。
那么,这里面的核心是什么呢?
- 组件与渲染
- 路由切换管理
- 状态管理
我会从这三个点展开讲,尽量不从代码层面,而从react哲学中进行输出,尽量从大到小,从粗到细。
二、组件与渲染
jsx与组件
这时react中的核心,react让我们不再手动的操作dom。在react中万物皆是组件,一个函数返回一些html标签是react世界中的硬通货,而这种组件的实现就是jsx语法(jsx与react均是独立的,vue也支持jsx了)。
function App(){
return(<div>
<p>这是一句话</p>
</div>)
}
这就是一个最基础的组件,但是对于jsx,相较于html里面多了一些要求。
- 返回的必须仅有一组标签。(这组标签内可以有很多标签,且返回的标签允许是空标签)
- 所有标签均需要闭合状态。
- 标签内传入的属性需要驼峰命名,不支持连接符
class在jsx为了和类组件区分要用className
组件拼接
如果把react项目当作一个巨大的积木,那么每一个组件就是其中的一块。
我们通过引入的方式将组件之间组合起来。
Child1.jsx
function Child1(){
return(<div>
<p>这是组件1</p>
</div>)}
export default Child1
Child2.jsx
function Child2(){
return(<div>
<p>这是组件2</p>
</div>)}
export default Child2
App.jsx
import Child1 from './Child1'
import Child2 from './Child2'
function App(){
return(<div>
<h1>这是父组件</h1>
<Child1 />
<Child2 />
</div>)
}
子组件通过export default的方式导出,父组件通过 impiort的方式导入。这样组件之间就拼接起来了。
组件渲染
父组件传值渲染
function App(){
return(<div>
<h1>这是父组件</h1>
<Child name='张三' />
</div>)
}
function Child({name}){
return(<div>
<p>name:{name}</p>
</div>)}
父组件通过向子组件发送一个属性,子组件进行接收,从而进行渲染。
列表渲染
function Child({name}){
return(<div>
<p>name:{name}</p>
</div>)}
function App(){
const list = ['张三','李四','王五','胖虎','大雄']
return(<div>
<h1>这是父组件</h1>
{list.map(item=> <Child name={item} />)}
</div>)
}
这里面可以看出,列表渲染是通过js中的map进行循环遍历,而且同样支持传值。
条件渲染
function Child({name,show}){
return(<div>
{show? <p>name:{name}</p> :<p> 不显示</p> }
</div>)}
function App(){
return(<div>
<h1>这是父组件</h1>
<Child name='张三' show='false' />
<Child name='李四' show='true' />
<Child name='王五' show='false' />
<Child name='胖虎' show='true' />
</div>)
}
可以看出这里面通过三元运算符进行条件渲染,这里面的条件,还可以放在style中控制样式。
【延申】:由代码可知,name和show是通过传递的props。当props发生变化的时候会触发重新渲染,但不一定会走完生命周期全程。这和Vue不同,Vue中使用v-if,修改状态后组件重新渲染,从新走生命周期。而React会忽略已经走完的Constructor和componentDidMount,直接到ComponentDidUpdate。如果想实现v-show的效果,在style属性上使用条件判断,控制display。
事件交互
想产生交互必须依赖于事件,比如鼠标点击,鼠标移动,键盘点击之类。但是最常用的就是点击事件。
那如何声明一个事件呢?事件一般由on开头,如onClick。
function App(){
const handleClick1 = () => {
console.log('你点击了')
}
const handleClick2 = (text) => {
console.log(text)
}
return(<div>
<button onClick={handleClick1}>点击1</button>
<button onClick={()=>handleClick2('你点击了')}>点击2</button>
</div>)}
在事件中,有两种调用函数的方式,如果只调用不传参,可以选择第一种。需要传参就需要第二种了。当然你也可以选择内联的写法,但是不利于阅读,不建议。
三、状态
在函数式组件中,因为不具备类组件的一些能力我们依赖hooks帮我们处理一些问题。比如管理状态,模拟生命周期,缓存计算值等,甚至利用这些hooks,我们可以把需要复用的逻辑抽象出来进行自定义hooks。一般这些hooks使用use开头。
状态管理
useState
在react中所有的状态都是不可变的,需要返回一个新的状态替换原来的状态。这也是react的核心概念。
这个是最常用的hooks没有之一。用于存储状态和利用状态进行渲染。
import {useState} from 'react'
function App(){
const [num,setNum] = useState(null)
//返回两个参数,用数组包裹,第一个参数是状态,第二个是设置状态。
const handleClick = () => {
setNum(num + 1)
}
return(<div>
<p>{num}</p>
<button onClick={handleClick}>点击数字加一</button>
</div>)}
这就是最基础的用法。
如果我们想更改一个对象呢?
import {useState} from 'react'
function App(){
const [obj,setObj] = useState({a:1,b:'abc'})
const handleClick = () => {
setObj({a:2,b:'abc'})
}
//如果这个对象非常的复杂,我们又只想改变其中的一个值
const handleClick = () => {
setObj({a:2,...obj})
}
return(<div>
<p>{num}</p>
<button onClick={handleClick}>点击修改对象</button>
</div>)}
注意,修改对象的时候,传参一定也要是对象。
状态流转
react使用单项数据流,即数据只能自上而下。如果在根组件中声明一个state,那么它可以通过props传到子组件,孙子组件。前面渲染部分其实已经体验到了。但这样就出现了一个问题,子组件可以使用父组件的状态,但是父组件没办法使用子组件的状态。
在vue中,通过双向绑定,声明一个状态,无论是父组件还是子组件都可以修改。但react不行。那react是如何进行数据流转的呢?
例子
父传子
function App(){
const [name,setName] = useState('张三')
return(<div>
<h1>这是父组件</h1>
<Child name={name} setName={setName}/>
</div>)
}
function Child({name,setName}){
return(<div>
<p>name:{name}</p>
<button onClick={()=>setName('李四')}></button>
</div>)}
这里我直接引用了前面的例子,不过加上了状态。这里,在父组件定义的状态就流转到了子组件。
并且当把setName也传过去,子组件拥有了修改状态的能力。
子传父
我们通过父传子,已经能感受到react的能力了。那么子传父就更容易理解了。
function App(){
const [name,setName] = useState('张三')
return(<div>
<h1>这是父组件</h1>
<p>name:{name}</p>
<Child setName={setName}/>
</div>)
}
function Child({setName}){
return(<div>
<button onClick={()=>setName('李四')}></button>
</div>)}
我们在父组件定义状态,将setName给子组件,这样子组件修改就可以在父组件展示了。
兄弟组件传值
function App(){
const [name,setName] = useState('张三')
return(<div>
<h1>这是父组件</h1>
<Child1 name={name}/>
<Child2 setName={setName}/>
</div>)
}
function Child1({name}){
return(<div>
<p>name:{name}</p>
</div>)}
function Child2({setName}){
return(<div>
<button onClick={()=>setName('李四')}></button>
</div>)}
还是在父组件定义状态,如果需要子组件2向子组件1传值,那么就可以将setName给子组件2,name给子组件1。
祖孙间传值
function Root(){
const [name,setName] = useState('张三')
return(<div>
<h1>这是根组件</h1>
<Child1 name={name} setName={setName}/>
</div>)
}
function App({name,setName}){
return(<div>
<h2>这是父组件</h2>
<Child2 name={name} setName={setName}/>
</div>)}
function Child({setName}){
return(<div>
<h3>这是子组件</h3>
<p>{name}</p>
<button onClick={()=>setName('李四')}></button>
</div>)}
还是在最顶层定义状态,把状态的所有参数逐层向下传递,达到传值的效果。
总结
由此发现,如果想多个组件共享同一个数据,那么就需要在这些组件共同的父组件进行定义状态,然后将状态和修改状态的方法通过Props属性不断的进行传递。但是这种方式过于复杂,最好不超过三层,不过如果有复杂需求,可以考虑使用统一状态管理Redux或使用钩子函数模拟一个小型的Redux。
useContext
上面的案例对于复杂需求很不友好,那么有什么更友好的方式?通过useContext对上下文进行管理。
App.jsx
import {useState,createContext} from 'react'
import Child1 from './Child1'
import Child2 from './Child2'
export const MyContext = createContext(null)
function App({name,setName}){
const [name,setName] = useState('张三')
const [age,setAge] = useState(20)
return(
<MyContext.Provider value={name,setName,age,setAge}>
<div>
<h2>这是父组件</h2>
<Child1 />
<Child2 />
</div>
</MyContext.Provider>)}
export default App
Child1.jsx
import {useContext} from 'react'
import {MyContext} from './App.jsx'
function Child1({setName}){
const {name,setName} = useContext(MyContext)
return(<div>
<h3>这是子组件1</h3>
<p>{name}</p>
<button onClick={()=>setName('李四')}></button>
</div>)}
export default Child1
Child2.jsx
import {useContext} from 'react'
import {MyContext} from './App.jsx'
function Child1({setName}){
const {age,setAge} = useContext(MyContext)
return(<div>
<h3>这是子组件2</h3>
<p>{age}</p>
<button onClick={()=>setAge(25)}></button>
</div>)}
export default Child1
可以看到,在App引用两个子组件的时候并没传递任何参数,子组件通过useContext就可以获取数据。非常好用。
模拟Redux
useReducer整合状态
使用useReducer就不得不提到发布订阅模式,此后使用redux也是这样。把所有的状态全都抽离出来,不存在于任何一个组件当中,而存放状态的地方可以称之为仓库。
在仓库中,如果想修改任何一个状态,都需要通过reducer进行修改。reducer是一个纯函数,即入参和返回相同,reducer接收两个值,一个是type,一个是action。通过switch对type进行匹配,这里会对switch里面的case进行遍历,直到匹配成功。
每一个case都是对state的处理。
App.jsx
import {useReducer,} from 'react'
import MyReducer,{initialState} from './MyReducer.jsx'
function App({name,setName}){
const [state,dipatch] = useReducer(MyReducer,initialState)
//返回两个参数,state和dispatch,入参是reducer和初始状态
return(
<div>
<p>name:{state.name}</p>
<p>age:{state.age}</p>
<button onClick={()=>dipatch({type:'updateName',payload:'李四'})}>修改name</button>
<button onClick={()=>dipatch({type:'updateAge',payload:30})}>修改age</button>
</div>
)}
MyReducer.jsx
//创建初始状态
export const initialState = {
name:'',
age:null
}
export default (state,{type,payload}) => {
switch(type){
case:'updateName':
return {...state,name:payload}
case:'updateAge':
return {...state,age:payload}
default:
return state
}
}
这样我们就用reducer把状态都整合起来了。如果希望状态能够共享,那么就可以利用useContext将组件上下文连接起来。
useContext连接
组件保持不变,我们在父组件外包裹Provider,在子组件中使用它。
App.jsx
import {useReducer,createContext} from 'react'
import MyReducer,{initialState} from './MyReducer.jsx'
import Child from './Child.jsx'
export const MyContext = createContext(null)
function App({name,setName}){
const [state,dipatch] = useReducer(MyReducer,initialState)
//返回两个参数,state和dispatch,入参是reducer和初始状态
return(
<MyContext.Provider value={state,dipatch}>
<div>
<Child/>
</div>
</MyContext.Provider>
)}
Child.jsx
import {useContext} from 'react'
import {MyContext} from './App.jsx'
function Child({setName}){
const {state,dipatch} = useContext(MyContext)
return(<div>
<p>name:{state.name}</p>
<p>age:{state.age}</p>
<button onClick={()=>dipatch({type:'updateName',payload:'李四'})}>修改name</button>
<button onClick={()=>dipatch({type:'updateAge',payload:30})}>修改age</button>
</div>)
}
export default Child
MyReducer.jsx
//创建初始状态
export const initialState = {
name:'',
age:null
}
export default (state,{type,payload}) => {
switch(type){
case:'updateName':
return {...state,name:payload}
case:'updateAge':
return {...state,age:payload}
default:
return state
}
}
Ok,现在你就有了一个模拟的redux了。
但是需要注意的是,在每个需要统一状态的地方都需要在顶层用Provider包裹。当组件过于复杂的时候可能造成阅读性不强的影响。
四、HOOKS
所有hooks遵循以下特点
- 不能在循环语句或条件语句中调用
- 只能在函数的顶层调用
useEffect与模拟生命周期
在类组件中,每个组件都拥有自己的生命周期,即在组件的创建,修改,结束每个时机都会有一个函数,我们可以在这样的周期函数中处理一些副作用。但是在函数式组件中显然没有这种概念。优势是代码的可读性更高,但是处理副作用就需要单独的hooks进行。
useEffect会帮你自动的处理一些代码,不依靠事件进行。比如在组件加载时进行数据请求。根据状态的更改自动处理dom。
useEffect有两个参数,第一个是回调函数,第二个是依赖数组。
如果传的是一个空数组则在渲染之后执行一次。(模拟componentDidMount)
import {useState,useEffect} from 'react'
function App({name,setName}){
const [name,setName] = useState('')
useEffect(()=>{
setName('李四')
},[])
//监听状态变化
return(
<div>
<p>name:{name}</p>
</div>
)}
如果你加入了依赖,则只有依赖更新了才会执行第一个回调函数,达到监听的效果。(模拟componentDidUpdate)
import {useState,useEffect} from 'react'
function App({name,setName}){
const [name,setName] = useState('')
useEffect(()=>{
console.log('状态修改触发副作用函数')
},[name])
//监听状态变化
return(
<div>
<p>name:{name}</p>
<button onClick={()=>setName('李四')}>点击</button>
</div>
)}
所以,要正确传入依赖,避免需要随状态更新而触发的时候没有触发。
如果需要在卸载组件时清除副作用,可以在回调函数种return一个函数(模拟componentWillUnmount)
import {useState,useEffect} from 'react'
function App({name,setName}){
const [count,setCount] = useState(1)
useEffect(()=>{
timer = setInterval(() => {
setCount(prevt=>prevt+1);
}, 1000);
return ()=>{
clearInterval(timer);
}
},[])
//监听状态变化
return(
<div>
<p>count:{count}</p>
</div>
)}
useEffect函数会在挂在和卸载各执行一次,如果有return则会在卸载的时候,处理清理函数,如果没有return则什么都不执行。
useRef
useRef一般有两个作用,第一:代替useState存放变量,第二:获取DOM。
-
存放变量
这些数据不会随着组件的重新渲染而发生变化,因此特别适用于存储那些不需要触发组件重渲染的数据。此外,
useRef还可以在函数组件之间共享可变的引用。 -
获取DOM
通过
useRef,我们可以获取到DOM元素对象的引用,从而能够直接对DOM进行操作。例如,可以获取input输入框的引用,然后通过这个引用设置输入框的值或触发输入框的聚焦事件。
变量是决定视图图层渲染的变量,请使用useState,其他用途useRef
useMemo与useCallBack
首先在开发中不会大量用到useMemo和useCallBack。它存在的意义是为了缓存计算好的属性,尤其是在重复渲染过程中,节省大量的资源。
默认情况下,React 会在每次重新渲染时重新运行整个组件。如果使用useMemo包裹,如果依赖项没有发生改变,则不会重新渲染。
当然有可能遇到修改的值没有及时储存到useState中,可以通过useMemo来缓存数据。
当然useMemo和useCallBack有相似的地方,但是用法不同,经常会发现使用useMemo没有效果,但是换成~又有效果,有时候反之。所以,使用useMemo和useCallBack会造成大量的心智负担,所以能不用就不用。
那如果仅为了应试,可以记useMemo缓存的是值,useCallBack缓存的是回调函数。
自定义hooks
使用自定义hooks是成为高手的必经之路。自定义 Hooks 是 React 中一种重用逻辑的方式。它们允许我们将组件逻辑提取到可重用的函数中,以便在多个组件中共享。
可以看出,如果你需要把某个hooks组合多次复用,则可以使用自定义hooks。
在工作中,不涉及hooks的逻辑可以抽象出来放到utils中,成为一个工具函数。那如果涉及hooks的逻辑就可以封装成自定义hooks。
不过基于工作中,这种机会普遍偏少,能想象的hooks都可以使用ahooks解决。
五、路由
现在我们已经会使用react了,但距离一个spa应用还有一段距离。首先是组件间的切换。
路由的意义在于修改url的值,就可以切换组件。而这个功能,由react-router提供。
在react-router v6中,可以使用hooks的方式进行操作。
安装
npm i react-router-dom
路由器选择
BrowserRouter和 HashRouter
首先你需要了解这两种路由模式。
- BrowserRouter 更符合现代 web 开发的标准,因为它使用标准的 URL,使得你的应用更易于书签保存、分享和搜索引擎索引。然而,它要求服务器能够正确配置以处理前端路由。对于单页应用(SPA),这通常意味着所有路由都应该返回相同的 index.html 文件。
- HashRouter 不需要服务器配置,但 URL 看起来不那么“干净”,且不支持一些浏览器功能,如通过 URL 直接导航到特定路由。它更适用于简单的应用或需要快速原型设计的场景。
在选择使用 BrowserRouter 还是 HashRouter 时,你应该考虑你的应用需求、目标受众以及服务器配置的可能性。在大多数情况下,BrowserRouter 是更好的选择,因为它提供了更现代、更用户友好的 URL 体验。
当然,你选择任何一种都不耽误你的开发,在我看来在条件允许的情况下尽量使用BrowserRouter
在顶层创建路由
只有在顶层创建路由才能被全部的组件使用。
App.js
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from "./Layout"
import Child1 from './Child1'
import Child2 from './Child2'
import NotFount from './NotFount'
function App (){
return (
<BrowserRouter> //路由器选择,这个是浏览器路由
<Routes> //必须由Routes路由器包裹
<Route path='/' element={<Layout />}> //一级路由,被包裹的是嵌套路由
<Route path='child1' element={< Child1/>}/>
<Route path='child2' element={< Child2/>}/>
<Route path='*' element={< NotFount/>}/>
</Route>
</Routes>
</BrowserRouter>
)
}
客户端路由
嵌套路由Outlet
Layout.js
import { Outlet } from "react-router-dom";
function Layout () {
return (
<div>
<SiderBar />
<Content>
<Outlet /> //布局中的占位符,用来放置嵌套路由
</Content>
</div>
)
}
Link跳转组件
SiderBar.jsx
import { Link } from "react-router-dom";
function SiderBar () {
return (
<ul>
<li><Link to='/home'>首页</Link></li>
<li><Link to='/child1'>子组件1</Link></li>
<li><Link to='/child2'>子组件2</Link></li>
</ul>
)
}
这里Link可以替换为NavLink来制作菜单栏,这样被选中的菜单就有active的属性
Navigate声明式导航和重定向
import { BrowserRouter, Routes, Route ,Navigate} from 'react-router-dom'
import Layout from "./Layout"
import Home from './Home'
import Child1 from './Child1'
import Child2 from './Child2'
import NotFount from './NotFount'
function App (){
return (
<BrowserRouter> //路由器选择,这个是浏览器路由
<Routes> //必须由Routes路由器包裹
<Route path='/' element={<Layout />}> //一级路由,被包裹的是嵌套路由
<Route path="*" element={<Navigate to="/home" />} /> //重定向
<Route path='home' element={<Home/>}/>
<Route path='child1' element={< Child1/>}/>
<Route path='child2' element={< Child2/>}/>
<Route path='*' element={< NotFount/>}/>
</Route>
</Routes>
</BrowserRouter>
)
}
这里Navigate组件是useNavigate的包装器。一般作为路由表中重定向部分。当然你也可以在组件中使用这个标签。
Hooks
useLocation
返回当前位置
import {useState,useEffect} from 'react'
import {useLocation} from "react-router-dom"
function App({name,setName}){
const [count,setCount] = useState(1)
let location = useLocation()
//模拟componentWillR,当跳转过来携带参数的情况下。
useEffect(()=>{
if(loaction.state){
...
}
},[location.state])
return(
<div>
</div>
)}
useNavigate
编程式的路由跳转
import { useNavigate } from "react-router-dom";
function MyButton() {
let navigate = useNavigate();
function handleClick() {
//编程跳转
navigate("/home");
//携带参数
navigate("/child1", { state: { key: "value" } });
}
return <button onClick={handleClick}>Submit</button>;
}
这是react-router中比较重要的hooks。允许编程式的跳转,代替了history.push。
useRoutes
允许创建路由表
import { useRoutes } from "react-router-dom";
import Layout from "./Layout"
import Home from './Home'
import Child1 from './Child1'
import Child2 from './Child2'
import NotFount from './NotFount'
function MyRouter () {
const elements = useRoutes([
{
path:'/',
element:<Layout/>,
children:[
{
path:'/',
element:<Home/>,
},
{
path:'/detail/:id',
element:<Child1/>,
},
{
path:'/about',
element:<Child2/>,
}
]
}
])
return elements
}
此时useRoutes会帮你生成一个路由树。
路由应用
路由拦截
在路由跳转之前,我们可能需要进行一些关于权限验证。那么,要实现路由拦截,则要对路由进行封装。
AuthRouter.jsx
import { Route, Navigate } from 'react-router-dom';
function AuthRouter({ element: Component, ...rest }) {
const isAuth = true; //这里可以根据具体需求判断用户是否登录
return (
<Route
{...rest}
element={
isAuth ? (
Component
) : (
<Navigate to="/" replace />
)
}
/>
);
}
App.jsx
import { Route, Navigate } from 'react-router-dom';
import AuthRouter from './AuthRouter'
import Home from './Home'
import Child1 from './Child1'
function App() {
return (
<div className="App">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/logout" element={<Navigate to="/" />} />
<Route path="/Home" element={<Home />} />
<AuthRouter path="/Child1" element={<Child1 />} />
</Routes>
</div>
);
}
路由懒加载
为什么需要路由懒加载呢?在我们首次打开的时候,会加载很多个组件,如果使用懒加载,即可以做到按需加载。是一个非常常用的功能。
这里我们使用的是Suspense, lazy进行懒加载。
Suspensing.jsx
function Suspensing (){
return (<h2>加载中</h2>)
}
//这里可以做成比较炫酷的加载特效
LazyLoad.jsx
import { lazy, Suspense } from "react";
import Suspensing from "./Suspensing"
function LazyLoad(path) {
const Comp = React.lazy(() => import({path});
return <Suspense fallback={<Suspensing/>}>
<Comp />
</Suspense>
}
MyRouter.jsx
import {useRoutes} from 'react-router-dom'
import LazyLoad from './LazyLoad'
const routers = [
{
path: '/home',
element: <LazyLoad path='./home' />)
},
{
path: '/Child1',
component: <LazyLoad path='./Child' />)
}
]
function MyRouter (){
const elements = useRoutes(routers)
return elements
}
export default MyRouter
这样就可以懒加载了。
六、统一状态管理
前言
统一状态管理是react中最复杂的地方,因为react非常灵活,所以统一状态管理的工具库也非常的多。
这里要推荐Zustand 。当然,你也可以考虑使用mobx,在我另一篇文章中。
在此之前,你要思考是否需要统一状态管理的工具?
如果你正在开发一个中后台管理系统。那么你大概率用不到统一状态管理。就算需要,使用useReducer与useContext也能满足需求。
那么什么时候需要呢?
-
在发生一个事件,涉及到的组件足够多。
我参与过一个需求。在8个组件中,每个组件均有一个搜索框,它们可以单独搜索,也可以通过父组件一键搜索。在一键搜索,要收集每个组件中的表单信息。那么就不能狗在父组件中统一声明这些状态,使用统一状态是更好的选择。
-
状态之间跨度足够大
假如孙子组件A,需要和孙子组件B共享状态,且两个组件不共用一个父组件,那么,这个跨度就非常大,不过这时还是可以使用
useContext共享状态,那么如果再复杂一些呢?这个时候可以使用统一状态管理。 -
如果你的状态经常在变化。
如果你需要记录一个状态,而这个状态实时性特别高,也可以考虑存放在统一状态管理中。
ok,我们现在知道了什么时候需要使用统一状态管理,而且恰巧我们马上要使用,在这之前我们不得不提一下redux。
不得不提的redux
redux是react官方指定的统一状态管理库,也是react目前下载量最大的状态管理库。但是为什么我不推荐使用呢?
- 单仓库,redux只有一个仓库,reducer也是一个,虽然可以分成几个后再次连接
- 样板很多,redux相比于其他库,样板代码太多,做不到开箱即用
- redux自身不支持异步,需要配置中间件
- redux持久化需要其他库的帮助
总之redux徒增心智负担,如果你的项目不大,不建议使用。
zustand和mobx
zustand是一个较新的状态管理库,基于的思想还是flux,即单向数据流。这里整体和redux非常像,也和react非常的契合。MobX主要的设计思想是观察者模式,用于在对象之间建立一种一对多的依赖关系。当被观察对象的状态发生变化时,所有依赖于它的观察者对象都会自动收到通知并进行相应的更新。在MobX中,store是被观察者,而使用store的组件则是观察者。
与之相比,mobx在应对复杂状态下,因为状态可以随时被修改,进而无法溯源。在操作易用性上,zustand更加容易上手。
目前我在使用的就这两个库,mobx接触的更早,接触的较多,zustand虽然用的晚,但是无论作为入门来说,还是未来的主力使用,zustand更加适合。
安装
npm i zustand //or yarn add zustand
目标
- 创建仓库
- 使用仓库
- 实现异步
使用
创建仓库
zustand的创建方式是直接利用hooks,所以你可以先创建一个stores的文件夹,集中存放所有的仓库。
import { create } from "zustand";
const useCountStore = create((set, get) => ({
count: 1,
price: 9.9,
add: () => set((state) => ({ count: state.count + 1 })),
remove: () => set((state) => ({ count: state.count - 1 })),
getReduce: () => {
const reduce = get().count * get().price;
return Math.round(reduce * 100) / 100;
},
}));
export default useCountStore;
我们使用create这个api创建一个仓库,仓库中包含一个回调函数,我们所有的操作都会在这个函数中进行。
函数的入参一个是set,一个是get,二者都能获取到state的值,使用set可以帮助我们修改state的值,使用get则不会,一般可以用来计算属性。函数返回的是一个对象,所以这里要用小括号包裹。
如果希望修改state中的值,可以使用函数,但返回值是set。set函数中也需要返回一个回调函数,入参是state,返回值是一个对象,对象中是需要修改的键值。
这里需要着重强调的是,set会对state进行浅拷贝,对于深层的复杂对象官方推荐使用immer这个库。之后会开一篇文档讲immer。
绑定组件
import useCountStore from "../store/countStore";
export default function Home() {
const price = useCountStore((state) => state.price);
const count = useCountStore((state) => state.count);
const add = useCountStore((state) => state.add);
const remove = useCountStore((state) => state.remove);
const getReduce = useCountStore((state) => state.getReduce);
return (
<div>
<h1>zustand计数器</h1>
<p>单价:{price}</p>
<p>数量:{count}</p>
<button onClick={add}>数量+</button>
<button onClick={remove}>数量-</button>
<hr />
<p>总价:{getReduce()}</p>
</div>
);
}
这里可以看到,仓库中的每一个属性都可以通过刚才创建的useCountStore获取。不过这种写法非常的臃肿。我们接下来会写一种推荐写法。
import useCountStore from "../store/countStore";
import { useShallow } from "zustand/react/shallow";
export default function Home() {
const { price, count, add, remove, getReduce } = useCountStore(
useShallow((state) => ({
price: state.price,
count: state.count,
add: state.add,
remove: state.remove,
getReduce: state.getReduce,
}))
);
return (
<div>
<h1>zustand计数器</h1>
<p>单价:{price}</p>
<p>数量:{count}</p>
<button onClick={add}>数量+</button>
<button onClick={remove}>数量-</button>
<hr />
<p>总价:{getReduce()}</p>
</div>
);
}
这里面引用了一个useShallowapi,其实大家可以进行尝试,如果不加这个api的情况下,会发生什么。一样可以获取到,但是所有的都会重复渲染,很明显这不是我们想要的。
使用useShalow阻止重复渲染。
在这种情况下,如果计算值始终较浅,则可能需要使用来避免重新渲染 等于前一个。
仓库切片
如果你有一个过大的仓库,代码的可读性非常糟糕,可以把仓库分割成一些小仓库,再对他们进行合并。
countSpliceA.js
export const countSpliceA = () => ({
count: 1,
price: 9.9,
});
countSpliceB.js
export const countSpliceB = (set, get) => ({
add: () => set((state) => ({ count: state.count + 1 })),
remove: () => set((state) => ({ count: state.count - 1 })),
getReduce: () => {
const reduce = get().count * get().price;
return Math.round(reduce * 100) / 100;
},
});
像这里我讲状态和修改状态的方法分开了。看看能不能通过create将它们连起来。
countStore.js
import { create } from "zustand";
import { countSpliceA } from "./countSliceA";
import { countSpliceB } from "./countSliceB";
const useCountStore = create((...a) => ({
...countSpliceA(...a),
...countSpliceB(...a),
}));
export default useCountStore;
此时会发现两个小仓库已经被连起来了。但是貌似在这个大仓库中不能使用set和get方法了。
使用异步
zustand无需管你是不是异步,更不要再装什么依赖。
countSpliceC.js
export const countSpliceC = (set) => ({
res: "这是一个异步的文字",
yibu: async () => {
const res = await Promise.resolve("异步执行成功");
set(() => ({ res }));
},
});
countSpliceC需要使用切片合并
Home.jsx
import useCountStore from "../store/countStore";
import { useShallow } from "zustand/react/shallow";
export default function Home() {
const { res, yibu } = useCountStore(
useShallow((state) => ({
res: state.res,
yibu: state.yibu,
}))
);
return (
<div>
<hr />
<h2>异步测试</h2>
<p>{res}</p>
<button onClick={yibu}>发起异步</button>
</div>
);
}
可以看出来,使用异步非常的简单,只需要通过async和await进行标注就行。你可以直接在函数内部发起请求,返回的数据通过处理可以直接使用。
中间件
当熟练掌握上面的部分,绝大多数情况应该都能应对。但是如果想让他扩展的更加好用,可以使用中间件来进行扩展。这里我们用Immer举例。在zustand中,对于深层对象,需推荐使用Immer,这样我们可以直接修改,不需要深拷贝,再修改,会变得很好用。
安装immer
npm i immer //or yarn add immer
这里我们更新一下countSpliceB.js
import { immer } from "zustand/middleware/immer";
export const countSpliceB = immer((set, get) => ({
add: () =>
set((state) => {
state.count = state.count + 1; //可以直接修改,这种写法更直观的展示
}),
remove: () =>
set((state) => {
state.count--; //上面写法的一种简写
}),
getReduce: () => {
const reduce = get().count * get().price;
return Math.round(reduce * 100) / 100;
},
}));
在这里我们可以直接更改state中的数据,而不需要返回一个新的对象。
我们尝试做一个深度复杂的数据对象,来进行操作和更改。
countImmerD.js 这只是个切片需要使用create合并。
import { immer } from "zustand/middleware/immer";
export const countImmerD = immer((set) => ({
bigArr: [
{
id: "123434",
type: "arr",
title: "title1",
arr: [
[{ id: "a123", type: "aaa", text: "kasehfku" }],
[{ id: "b234", type: "bbb", text: "ukahfa" }],
],
},
{ id: "3243212", title: "title2", type: "text" },
{ id: "342341", title: "title3", type: "img" },
],
handleBigArr: (dlId, elementId, text) =>
set((state) => {
let current;
let dl = state.bigArr.find((dlItem) => dlItem.id === dlId);
dl.arr.forEach((arrItem) => {
current = arrItem.find((eleItem) => eleItem.id === elementId);
});
current.text = text;
}),
}));
Home.jsx
import useCountStore from "../store/countStore";
import { useShallow } from "zustand/react/shallow";
export default function Home() {
const { bigArr, handleBigArr} = useCountStore(
useShallow((state) => ({
bigArr: state.bigArr,
handleBigArr: state.handleBigArr,
}))
);
return (
<div>
<h2>测试渲染不可变数据</h2>
{bigArr.map((item) => (
<div>
{item.type === "arr" && (
<ul>
{item.arr.map((theArr) => (
<div>
{theArr.map((ele) => (
<p
style={ele.type === "bbb" ? { background: "red" } : null}
>
{ele.text}
</p>
))}
</div>
))}
</ul>
)}
</div>
))}
<button onClick={() => handleBigArr("123434", "b234", "abcdefghijk")}>
修改二维数组的text为abcdefghijk
</button>
</div>
);
}
这里可以看到,我们通过find查询到的当前对象,不需要对整个数组进行深拷贝,从而直接可以进行修改。而且新能非常好。
总结
到这里应该就能完整的做一个项目了。只是在一般的工作中,我们需要利用antd作为ui组件,用echarts作为在图表展示。所以想完成一个前端项目还需要学习很多常用库。