diff --git a/docs/.vitepress/const/links.ts b/docs/.vitepress/const/links.ts index af07b0c2..5deb5b9c 100644 --- a/docs/.vitepress/const/links.ts +++ b/docs/.vitepress/const/links.ts @@ -17,5 +17,7 @@ export const notes = [ { text: '前端工程化', link: '/note/Front-end Engineering' }, { text: '服务端渲染', link: '/note/SSR' }, { text: 'React基础', link: '/note/React' }, + { text: 'Redux', link: '/note/Redux' }, + { text: 'React Router', link: '/note/React Router' }, { text: 'MySQL', link: '/note/MySQL' } ] diff --git a/docs/note/React Router.md b/docs/note/React Router.md new file mode 100644 index 00000000..5991a6cb --- /dev/null +++ b/docs/note/React Router.md @@ -0,0 +1,482 @@ +## React Router + +三大框架都有各自的路由实现 + +- Angular ngRouter +- React ReactRouter +- Vue VueRouter + +React Router在最近两年的版本更新较快,并且在最新的React Router6发生了较大的变化 + +- Web开发只需要安装`react-router-dom` +- `react-router`包含一些ReactNative的内容 + +```bash +npm i react-router-dom +``` + +从`react-router-dom`中导出`BrowserRouter` 或 `HashRouter`,二者分别对应history模式与哈希模式 + +将App用二者之一包裹,即可启用路由: + +```tsx {5,10,12} +// index.js +import React, { StrictMode } from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import { HashRouter } from 'react-router-dom' + +const root = ReactDOM.createRoot(document.getElementById('root')) +root.render( + + + + + +) +``` + +路由的本质是路径与组件的映射关系(`path <==> component`) + +ReactRouter不像VueRouter,它的路由映射关系是书写在组件中的: + +下面的例子中使用到了几个**组件**:`Routes` `Route` `Navigate` `NavLink` + +- `Routes` `Route`用来描述路径与组件的映射关系 + - 通过为`path`和`element`传入路径和相对应的组件,将其包裹在`Routes`内即可完成路由的描述 +- `Navigate` 导航组件(在react-router5版本中是Redirect) + - 可以帮我们完成重定向操作,将想要重定向的路径传递给组件的`to`属性 + - **当组件出现时,就会自动执行跳转**,属于功能性组件 + - 当访问根路径`/`时就会自动跳转到`/home`页 +- `NavLink`用来实现路由的跳转 + - 特殊组件,其`className` `style`这些属性都可以传递一个函数 + - 可以从函数参数中解构出`isActive`属性来动态绑定样式(实际场景应用较少) + +```tsx +// App.js +import React, { PureComponent } from 'react' +import { Routes, Route, Navigate, NavLink } from 'react-router-dom' +import Home from './views/Home' +import About from './views/About' +import NotFound from './views/NotFound' + +export default class App extends PureComponent { + render() { + return ( +
+

App

+ (isActive ? 'link-active' : '')}> + Home + + ({ color: isActive ? 'red' : '' })}> + About + + + }> + }> + }> + }> + +
+ ) + } +} +``` + +另外,这里还有一个小技巧,在最末一个路由指定一个path为`*`的路由匹配规则,可以为路由匹配添加fallback策略,当未匹配到其之前的任何域名时,会展示NotFound页面 + +## 嵌套路由 + +嵌套路由可以通过在`Route`组件内部嵌套新的`Route`组件来实现 + +再通过`Outlet`组件来指定嵌套路由的占位元素(类似于VueRouter中的router-view) + +我们在之前的例子的基础上,为Home页面添加两个子页面HomeRanking和HomeRecommand + +同时,我们也应该为Home组件添加默认跳转,就像根路径默认重定向到Home组件那样,进入到Home组件后也应该默认重定向一个子页面中,这里我们仍然使用到了Navigate组件 + +::: code-group +```tsx [App.jsx ] +// App.jsx +import React, { PureComponent } from 'react' +import { Routes, Route, Navigate, NavLink } from 'react-router-dom' +import Home from './views/Home' +import HomeRanking from './views/HomeRanking' +import HomeRecommand from './views/HomeRecommand' + +export default class App extends PureComponent { + render() { + return ( +
+ + }> + }> + }> + }> + + +
+ ) + } +} +``` +```tsx [Home.jsx] +// Home.jsx +import React, { PureComponent } from 'react' +import { NavLink, Outlet } from 'react-router-dom' + +export default class Home extends PureComponent { + render() { + return ( +
+
Home
+ Ranking + +
+ ) + } +} +``` +::: + +## 编程式导航(高阶组件) + +之前使用的ReactRouter提供的路由跳转的组件,无论是`Link`还是`NavLink`可定制化能力都比较差,无法实现“点击按钮后跳转路由”这样的需求,那么我们就需要通过编程式导航,使用JS来完成路由的跳转 + +ReactRouter提供了编程式导航的API:`useNavigate` + +自ReactRouter6起,编程式导航的API不再支持ClassComponent,全面拥抱Hooks。 + +我们将在后续的学习中开启Hooks的写法,那么目前如何在类组件中也能使用Hooks呢?答案是高阶组件 + +封装一个高阶组件`withRouter`,经过高阶组件处理的类组件的props将会携带router对象,上面包含一些我们需要的属性和方法: + +::: code-group +```tsx [withRouter.js] +// withRouter.js +import { useNavigate } from 'react-router-dom' + +export function withRouter(WrapperComponent) { + return (props) => { + const navigate = useNavigate() + const router = { navigate } + return + } +} +``` +```tsx [Home.jsx] +// Home.jsx +import React, { PureComponent } from 'react' +import { Outlet } from 'react-router-dom' +import { withRouter } from '../hoc/withRouter' + +export default withRouter( + class Home extends PureComponent { + render() { + return ( +
+
Home
+ + + +
+ ) + } + } +) +``` +::: + +我们使用`withRouter`高阶组件对Home组件进行了增强,可以通过编程式导航来实现二级路由跳转 + +这里只是展示了编程式导航的用法和高阶组件的能力,目前还是尽可能使用Hooks写法编写新项目 + +## 动态路由(路由传参) + +传递参数由两种方式: + +- 动态路由的方式 +- 查询字符串传递参数 + +动态路由是指:路由中的**路径**信息并不会固定 + +- 比如匹配规则为`/detail/:id`时,`/detail/123` `detail/888`都会被匹配上,并将`123/888`作为id参数传递 +- 其中`/detail/:id`这个匹配规则被称为动态路由 + +动态路由常见于嵌套路由跳转,比如:从歌曲列表页面点击后跳转到歌曲详情页,可以通过路由传递歌曲的ID,访问到不同歌曲的详情页 + +我们在之前的HomeRanking榜单中加入列表和点击跳转功能,并编写一个新的组件Detail来接收来自路由的参数 + +同样地,`react-router-dom`为我们提供了从路由获取参数的API:`useParams`,它是一个Hooks,我们将它应用到之前编写的高级组件`withRouter`中 + +- 在使用了`withRouter`的组件中,就可以通过`this.props.router.params.xxx`获取到当前路由中传递的参数 +- 使用动态匹配路由时,传递给Route组件的`path`属性为`:xxx`,这里是`/detail/:id` + +::: code-group +```tsx [withRouter.js] +// withRouter.js +import { useNavigate, useParams } from 'react-router-dom' + +export function withRouter(WrapperComponent) { + return (props) => { + const navigate = useNavigate() + const params = useParams() + const router = { navigate, params } + return + } +} +``` +```tsx [HomeRanking.jsx] +// HomeRanking.jsx +import React, { PureComponent } from 'react' +import { withRouter } from '../hoc/withRouter' + +export default withRouter( + class HomeRanking extends PureComponent { + render() { + const list = Array.from(Array(10), (x, i) => ({ + id: ++i, + name: `Music ${i}` + })) + return ( +
+
HomeRanking
+
    + {list.map((item, index) => ( +
  • this.props.router.navigate(`/detail/${item.id}`)}> + {item.name} +
  • + ))} +
+
+ ) + } + } +) +``` +```tsx [Detail.jsx] +// Detail.jsx +import React, { PureComponent } from 'react' +import { withRouter } from '../hoc/withRouter' + +export default withRouter( + class Detail extends PureComponent { + render() { + return ( +
+
Detail
+ Current Music ID: {this.props.router.params.id} +
+ ) + } + } +) +``` +::: + +### 查询字符串的参数 + +之前传递的是路径参数,那么查询字符串参数应该如何获取? + +可以通过`useLocation`这个Hooks拿到当前地址详细信息: + +```tsx +const location = useLocation() +location.search // ?name=ziu&age=18 +``` + +需要自行完成数据的解析,不太方便 + +还有一个Hooks:`useSearchParams`,可以在获取到查询字符串信息的同时帮我们解析成`URLSearchParams`对象 + +要从`URLSearchParams`类型的对象中取值,需要通过标准方法`get` + +```tsx +const [ searchParams, setSearchParams ] = useSearchParams() +searchParams.get('name') // 'ziu' +searchParams.get('age') // 18 +``` + +当然,我们在实际使用中也可以通过`Object.fromEntries`将它转为普通对象,这样我们使用`useSearchParams`来对之前编写的高阶组件`withRouter`做一次增强: + +```tsx {8,9} +// withRouter.js +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' + +export function withRouter(WrapperComponent) { + return (props) => { + const navigate = useNavigate() + const params = useParams() + const [searchParams] = useSearchParams() + const query = Object.fromEntries(searchParams) + const router = { navigate, params, query } + return + } +} +``` + +::: tip +需要注意的是,这里的`useSearchParams`是一个Hooks的常见形态 + +它返回一个数组,数组的首位为值,数组的次位为改变值的方法 + +与对象解构不同的是,数组结构是对位解构:保证位置一致则值一致,命名随意 + +而对象解构恰恰相反,不必保证位置,而需要保证命名一致 +::: + +## 路由的配置方式 + +至此为止,路由的配置是耦合在`App.jsx`中的,我们可以将Routes这部分代码抽离出单独的组件,也可以通过配置的方式来完成路由映射关系的编写 + +- 在ReactRouter5版本中,我们可以将路由的映射规则写为JS对象,需要引入第三方库`react-router-config` +- 在ReactRouter6版本中,允许我们将其写为配置文件,不需要安装其他内容 + +6版本为我们提供了一个API:`useRoutes`,将我们编写的配置文件传入此函数,可以将其转化为之前编写的组件结构,本质上也是一种语法糖 + +需要注意的是,Hooks只能在函数式组件中使用,这里我们将App组件改用FunctionComponent书写了 + +::: code-group +```tsx [index.js] +// router/index.js +import { Navigate } from 'react-router-dom' +import Home from '../views/Home' +import HomeRanking from '../views/HomeRanking' +import HomeRecommand from '../views/HomeRecommand' +import About from '../views/About' +import Detail from '../views/Detail' +import NotFound from '../views/NotFound' + +export const routes = [ + { + path: '/', + element: + }, + { + path: '/home', + element: , + children: [ + { + path: '', + element: + }, + { + path: 'ranking', + element: + }, + { + path: 'recommand', + element: + } + ] + }, + { + path: '/about', + element: + }, + { + path: '/detail/:id', + element: + }, + { + path: '*', + element: + } +] +``` +```tsx [App.jsx] +import React from 'react' +import { NavLink, useRoutes } from 'react-router-dom' +import { routes } from './router' + +export default function App() { + return ( +
+

App

+ (isActive ? 'link-active' : '')}> + Home + + ({ color: isActive ? 'red' : '' })}> + About + + {useRoutes(routes)} +
+ ) +} +``` +::: + +## 懒加载 + +针对某些场景的首屏优化,我们可以根据路由对代码进行分包,只有需要访问到某些页面时才从服务器请求对应的JS代码块 + +可以使用`React.lazy(() => import( ... ))`对某些代码进行懒加载 + +结合之前使用到的配置式路由映射规则,我们使用懒加载对代码进行分包 + +```tsx {6,7,18,24} +// router/index.js +import { lazy } from 'react' +// import HomeRecommand from '../views/HomeRecommand' +// import About from '../views/About' + +const HomeRecommand = lazy(() => import('../views/HomeRecommand')) +const About = lazy(() => import('../views/About')) + +export const routes = [ + ... + { + path: '/home', + element: , + children: [ + ... + { + path: 'recommand', + element: + } + ] + }, + { + path: '/about', + element: + }, + ... +] +``` + +这时在终端执行`pnpm build`可以发现,构建产物为我们执行了分包,`About`和`HomeRecommand`这两个次级页面被打进了两个单独的包中 + +> 在Vue中默认为我们完成了代码分包,第三方包的代码都被打包到了`vendors`中,业务代码放到了单独的JS文件中 + +> 只有当我们访问到这些页面时,才会发起网络请求,请求这些次级页面的JS代码 + +然而如果你在react-app的构建产物`index.html`开启本地预览服务器,会发现切换到对应页面后项目会crash(本地开发也会crash) + +```bash +# 使用 serve 开启本地预览服务器 +pnpm add serve -g +serve -s build # 将 build 作为根目录 +``` + +这是因为React默认没有为异步组件做额外处理,我们需要使用`Suspense`组件来额外处理懒加载的组件 + +```tsx +// index.js +import React, { StrictMode, Suspense } from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import { HashRouter } from 'react-router-dom' + +const root = ReactDOM.createRoot(document.getElementById('root')) +root.render( + + + Loading...}> + + + + +) +``` + +当根组件内部有组件处于异步加载状态时,都会在页面上展示`Loading...`而不是崩溃掉 diff --git a/docs/note/React.md b/docs/note/React.md index 9342e251..f027847a 100644 --- a/docs/note/React.md +++ b/docs/note/React.md @@ -3726,1622 +3726,3 @@ classNames('foo', 'bar') classNames('foo', { bar: true }) classNames(...['foo', 'bar']) ``` - -## Redux - -- Redux的核心思想 -- Redux的基本使用 -- React结合Redux -- Redux的异步操作 -- redux-devtool -- reducer的模块拆分 - -### 理解JavaScript的纯函数 - -- 函数式编程中有一个非常重要的概念 **纯函数**,JavaScript符合函数式编程的范式,所以也有纯函数的概念 - - 在React开发中,纯函数被多次提及: - - React组件被要求像一个纯函数(为什么是像,因为还有类组件) - - Redux中有一个reducer的概念,同样是要求必须是一个纯函数 -- 掌握纯函数对于理解很多框架的设计都是有帮助的 - -一个纯函数必然具备以下特征: - -- 确定的输入一定产生确定的输出 -- 函数的执行过程中,不能产生副作用 - - -### 为什么需要Redux - -- JS需要管理的状态越来越多,越来越复杂 -- 状态不断发生变化之间又相互依赖,这要求视图层也能同步更新 -- React提供了自动更新视图的方法,但状态仍需要手动管理 -- Redux可以帮我们管理状态,提供了**可预测的状态管理** -- 框架无关,体积只有2KB大小 - -### Redux的核心理念 - -Redux的核心理念 Store - -- 定义一个统一的规范来操作数据,这样就可以做到对数据的跟踪 -- `list.push()` `list[0].age = 18` - -Redux的核心理念 Action - -- Redux要求:要修改数据,必须通过Action来修改 -- 所有数据的变化,必须通过派发(Patch)Action来更新 -- Action是一个普通的JS对象,用来描述此次更新的type与content -- `const action = { type: 'ADD_ITEM', item: { name: 'Ziu', age: 18 } }` - -Redux的核心理念 Reducer - -- 如何将Store和Action联系在一起? -- reducer是一个纯函数 -- 完成的工作就是:将传入的state和action结合起来,生成一个新的state -- `patch` => `reducer` => `newState` => `Store` - -### Redux Demo - -下例中,通过`createStore`创建了一个Store(已经不推荐了) - -- initialState用于在调用`createStore`时作为默认值传入`reducer` -- 后续每次`store.dispatch`都会调用`reducer` -- 通过`reducer`更新state中的数据 - -在React中,可以通过`store.subscribe`注册State变化的监听回调 - -- 当state发生变化时,通过调用`this.forceUpdate`触发组件的更新 -- 一般情况下,我们在`componentDidMount`注册监听回调,在`componentWillUnmount`解除监听 - -::: code-group -```tsx [App.jsx] -// App.jsx -import React, { PureComponent } from 'react' -import store from './store' - -export default class App extends PureComponent { - componentDidMount() { - // Subscribe to the store - store.subscribe(() => { - console.log('subscribe', store.getState()) - this.forceUpdate() - }) - } - - componentWillUnmount() { - store.unsubscribe() - } - - render() { - return ( -
-

App

-

Count: {store.getState().count}

-

Name: {store.getState().name}

- - - -
- ) - } -} -``` -```tsx [index.js] -// store/index.js -import { createStore } from 'redux' - -// The initial application state -// This is the same as the state argument we passed to the createStore function -const initialState = { - count: 0, - name: 'Ziu' -} - -// Reducer: a pure function that takes the previous state and an action, and returns the next state. -// (previousState, action) => newState -function reducer(state = initialState, action) { - console.log('reducer', state, action) - - switch (action.type) { - case 'INCREMENT': - // NOTE: Keep functions pure - do not mutate the original state. - // Desctructure the state object and return a **new object** with the updated count - // Instead of `return state.count++` - return { - ...state, - count: state.count + 1 - } - case 'DECREMENT': - return { - ...state, - count: state.count - 1 - } - case 'CHANGE_NAME': - return { - ...state, - name: action.name - } - default: - return state - } -} - -const store = createStore(reducer) - -export default store -``` -::: - -![redux-usage](./React.assets/redux-usage.svg) - -### 进一步封装 - -可以将耦合在一起的代码拆分到不同文件中 - -- 将`reducer`抽取出来`reducer.js`,简化`store/index.js`内容 -- 将`action.type`抽取为常量`constants.js`,使用时做导入,以保证一致性 -- 将`action`抽取出来`actionFactory.js`,用于外部dispatch时规范类型 - -::: code-group -```tsx [index.js] -// store/index.js -import { createStore } from 'redux' -import reducer from './reducer' - -const store = createStore(reducer) - -export default store -``` -```tsx [constants.js] -// constants.js -export const INCREMENT = 'INCREMENT' -export const DECREMENT = 'DECREMENT' -export const CHANGE_NAME = 'CHANGE_NAME' -``` -```tsx [reducer.js] -// reducer.js -import * as actionType from './constants' - -const initialState = { - count: 0, - name: 'Ziu' -} - -export default function reducer(state = initialState, action) { - switch (action.type) { - case actionType.INCREMENT: - return { - ...state, - count: state.count + 1 - } - case actionType.DECREMENT: - return { - ...state, - count: state.count - 1 - } - case actionType.CHANGE_NAME: - return { - ...state, - name: action.name - } - default: - return state - } -} -``` -```tsx [actionFactory.js] -// actionFactory.js -import * as actionType from './constants' - -export const increment = () => ({ - type: actionType.INCREMENT -}) - -export const decrement = () => ({ - type: actionType.DECREMENT -}) - -export const changeName = (name) => ({ - type: actionType.CHANGE_NAME, - name -}) -``` -::: - -```tsx -// App.jsx -import React, { PureComponent } from 'react' -import store from './store' -import { increment, decrement, changeName } from './store/actionFactory' - -export default class App extends PureComponent { - componentDidMount() { - store.subscribe(() => this.forceUpdate()) - } - componentWillUnmount() { - store.unsubscribe() - } - render() { - return ( -
-

App

-

Count: {store.getState().count}

-

Name: {store.getState().name}

- - - -
- ) - } -} -``` - -### Redux的三大原则 - -单一数据源 - -- 整个应用程序的状态都被存储在一棵Object Tree上 -- 且这个Object Tree只存储在一个Store中 -- 但Redux并不强制限制创建多Store,不利于数据维护 -- 单一数据源有利于整个应用程序的维护、追踪、修改 - -State属性是只读的 - -- 允许修改State的方法只有patch action,不要直接修改State -- 确保了View或网络请求都不能修改State -- 保证所有的修改都能被追踪、按照严格的顺序执行,不用担心竞态(race condition)的问题 - -使用纯函数来执行修改 - -- 通过reducer将旧State与新State联系在一起,并且返回一个**新的State** -- 随着应用程序复杂程度增加,可以将reducer拆分为多个小的reducer,分别用于操作不同State Tree的某一部分 -- 所有的reducer都应该是纯函数,不能产生任何的副作用 - -### 优化重复代码 - -当编写了一些案例的时候会发现,React结合Redux时会编写很多重复的代码 - -在每个需要用到Redux中状态的组件中,都需要在不同生命周期做添加订阅/解除订阅的处理,组件初始化时还要从store中取最新的状态 - -针对重复代码的问题,可以使用之前学到的高阶组件来做优化 - -Redux官方提供的库`react-redux`,可以让我们更方便的在React中使用Redux - -```bash -npm i react-redux -``` - -在Profile组件中,通过高阶函数`connect`实现的 - -将store中需要的状态通过`mapStoreToProps`转为props,并将需要使用store中状态的组件传入调用connect返回的函数中 - -在`Profile`组件中就可以从props中获取到store中的状态 - -::: code-group -```tsx [App.jsx] -// App.jsx -import React, { PureComponent } from 'react' -import { Provider } from 'react-redux' -import store from './store' -import Profile from './Profile' - -export default class App extends PureComponent { - render() { - return ( - -
-

App

- -
-
- ) - } -} -``` -```tsx [Profile.jsx] -// Profile.jsx -import React, { Component } from 'react' -import { connect } from 'react-redux' - -// mapStateToProps is a function that -// takes the state of the store as an argument -// and returns an object with the data that the component needs from the store. -// component will receive the data as props. -const mapStateToProps = (state) => ({ - count: state.count -}) - -export default connect(mapStateToProps)( - class Profile extends Component { - render() { - return ( -
-

Profile

-

Count: {this.props.count}

-
- ) - } - } -) -``` -::: - -我们刚刚只是完成了对State的映射,将Store中保存的全局状态state映射到了Profile组件的props中 - -connect还可以传入第二个参数,用于将action也映射到props中: - -```tsx {4,10-13,17,25,26} -// Profile.jsx -import React, { Component } from 'react' -import { connect } from 'react-redux' -import { INCREMENT, DECREMENT } from './store/constants' - -const mapStateToProps = (state) => ({ - count: state.count -}) - -const mapDispatchToProps = (dispatch) => ({ - increment: () => dispatch({ type: INCREMENT }), - decrement: () => dispatch({ type: DECREMENT }) -}) - -export default connect( - mapStateToProps, - mapDispatchToProps -)( - class Profile extends Component { - render() { - return ( -
-

Profile

-

Count: {this.props.count}

- - -
- ) - } - } -) -``` - -本质上是`connect`内部对操作进行了封装,把逻辑隐藏起来了: - -- 调用`connect`这个**高阶函数**,返回一个**高阶组件** -- 为高阶组件传入映射目标组件,最后高阶组件返回一个新组件 -- 新组件的props包含了来自Store中状态/dispatch的映射 - -### 异步Action - -有些场景下,我们希望组件能够直接调用Store中的action来触发网络请求,并且获取到数据 - -但是dispatch只允许派发对象类型的Action,不能通过dispatch派发函数 - -可以通过中间件`redux-thunk`来对Redux做增强,让dispatch能够对函数进行派发 - -```bash -npm i redux-thunk -``` - -通过`applyMiddleware`引入`redux-thunk`这个中间件: - -::: code-group -```tsx [index.js] {2,3,6} -// store/index.js -import { createStore, applyMiddleware } from 'redux' -import thunk from 'redux-thunk' -import reducer from './reducer' - -const store = createStore(reducer, applyMiddleware(thunk)) - -export default store -``` -```tsx [actionFactory.js] -// actionFactory.js -export const fetchPostList = () => { - return (dispatch, getState) => { - fetch('https://jsonplaceholder.typicode.com/posts') - .then((res) => res.json()) - .then((res) => { - dispatch({ - type: actionType.FETCH_POST_LIST, - list: res - }) - }) - } -} -``` -```tsx [list.jsx] -// list.jsx -import React, { Component } from 'react' -import { connect } from 'react-redux' -import { fetchPostList } from './store/actionFactory' - -const mapStateToProps = (state) => ({ - list: state.list -}) - -const mapDispatchToProps = (dispatch) => ({ - fetchList: () => dispatch(fetchPostList()) -}) - -export default connect( - mapStateToProps, - mapDispatchToProps -)( - class Profile extends Component { - render() { - return ( -
-

List

- - {this.props.list.length && ( -
    - {this.props.list.map((item) => ( -
  • {item.title}
  • - ))} -
- )} -
- ) - } - } -) -``` -::: - -- 这样就可以将网络请求的具体逻辑代码隐藏到Redux中 -- 将网络请求归于状态管理的一部分 -- 而不是书写在组件内,不利于维护,耦合度太高 - -`redux-thunk`是如何做到可以让我们发送异步请求的? - -- 默认情况下`dispatch(action)`的action必须为一个JS对象 -- `redux-thunk`允许我们传入一个函数作为`action` -- 函数会被调用,并且将`dispatch`函数和`getState`函数作为入参传递给这个函数action - - `dispatch` 允许我们在这之后再次派发`action` - - `getState` 允许我们之后的一些操作依赖原来的状态,可以获取到之前的状态 - -下图展示了从组件调用方法,触发patch到Redux接收patch、发送网络请求、更新state的全过程: - -![redux-async-action](./React.assets/redux-async-action.svg) - -### 拆分Store - -拆分Store带来的益处很多,便于多人协作、不同业务逻辑解耦等 - -在Redux中,拆分Store的本质是拆分不同的`reducer`函数,之前在使用`createStore`时,传入的就是`reducer`函数 - -之前的Store写法与用法: - -```tsx {8-9} -// store/index.js -import { createStore } from 'redux' -import reducer from './reducer' - -const store = createStore(reducer) - -// App.jsx -store.getState().count -store.getState().list -``` - -拆分Store后的写法与用法: - -```tsx {7-8,14-15} -// store/index.js -import { createStore, combineReducers } from 'redux' -import counterReducer from './counter' -import postListReducer from './postList' - -const reducer = combineReducers({ - counter: counterReducer, - postList: postListReducer -}) - -const store = createStore(reducer) - -// App.jsx -store.getState().counter.count -store.getState().postList.count -``` - -拆分为多个Reducer之后,需要首先`getState()`获取到整个状态树,随后指定获取到不同的模块中的状态 - -拆分后,不同模块下的文件是保持一致的: - -```sh -- store/ # Store根目录 - - index.js # 导出 store 位置 - - counter/ # Counter模块 - - actionFactory.js - - constants.js - - index.js # 统一导出 - - reducer.js - - postList/ # PostList模块 - - actionFactory.js - - constants.js - - index.js - - reducer.js - - ... -``` - -#### combineReducer函数 - -前面拆分Store时用到了`combineReducer`函数,将多个模块reducer组合到一起,函数内部是如何处理的? - -- 将传入的reducers合并到一个对象中,最终返回一个`combination`函数(相当于未拆分时传给`createStore`的`reducer`函数) -- 在执行`combination`函数的过程中,它会通过判断前后返回的数据是否相同来决定返回之前的state还是新的state -- 新state会触发订阅者发生对应更新,而旧state可以有效地组织订阅者发生刷新 - -下面简单写了一下`combineReducer`的实现原理 - -```ts -// 使用 -combineReducer({ - counter: combineReducer, - postList: postListReducer -}) - -// 创建一个新的reducer -function reducer(state = {}, action) { - // 返回一个对象 是Store的state - return { - counter: counterReducer(state.counter, action), - postList: postListReducer(state.postList, action) - } -} -``` - -## ReduxToolkit - -- ReduxToolkit重构 -- ReduxToolkit异步 -- connect高阶组件 -- 中间件的实现原理 -- React状态管理选择 - -### 认识ReduxToolkit - -之前在使用`createStore`创建Store时会出现deprecated标识,推荐我们使用`@reduxjs/toolkit`包中的`configureStore`函数 - -Redux Toolkit是官方推荐编写Redux逻辑的方法 - -- 在前面学习Redux时已经发现,Redux的逻辑编写过于繁琐、麻烦 -- 代码分拆在不同模块中,存在大量重复代码 -- Redux Toolkit旨在成为编写Redux逻辑的标准方式,从而解决上面提到的问题 -- 这个包常被称为:RTK - -### 使用ReduxToolkit重写Store - -Redux Toolkit依赖于react-redux包,所以需要同时安装这二者 - -```bash -npm i @reduxjs/toolkit react-redux -``` - -Redux Toolkit的核心API主要是下述几个: - -- `configureStore` 包装createStore以提供简化的配置选项和良好的默认值 - - 可以自动组合你的slice reducer 添加你提供的任何Redux中间件 - - 默认包含redux-thunk,并启用Redux DevTools Extension -- `createSlice` 创建切片 片段 - - 接受reducer函数的对象、切片名称和初始状态值,并自动生成切片reducer,并带有actions -- `createAsyncThunk` - - 接受一个动作类型字符串和一个返回Promise的函数 - - 并生成一个`pending / fullfilled / rejected`基于该承诺分派动作类型的thunk - -写一个Demo: - -::: code-group -```tsx [index.js] -// store/index.js -import { configureStore } from '@reduxjs/toolkit' -import counterSlice from './features/counter' - -const store = configureStore({ - reducer: { - counter: counterSlice - } -}) - -export default store -``` -```tsx [counter.js] -// store/features/counter.js -import { createSlice } from '@reduxjs/toolkit' - -const counterSlice = createSlice({ - name: 'counter', - initialState: { - count: 0 - }, - reducers: { - addCount(state, action) { - const { payload } = action - state.count += payload - }, - subCount(state, action) { - const { payload } = action - state.count -= payload - } - } -}) - -const { actions, reducer } = counterSlice - -export const { addCount, subCount } = actions - -export default reducer -``` -```tsx [Counter.jsx] -// Counter.jsx -import React, { Component } from 'react' -import { connect } from 'react-redux' -import { addCount, subCount } from '../store/features/counter' - -const mapStateToProps = (state) => ({ - count: state.counter.count -}) - -const mapDispatchToProps = (dispatch) => ({ - increment: (count) => { - const action = addCount(count) - return dispatch(action) - }, - decrement: (count) => { - const action = subCount(count) - return dispatch(action) - } -}) - -export default connect( - mapStateToProps, - mapDispatchToProps -)( - class Counter extends Component { - render() { - const { count } = this.props - - return ( -
-

Counter

-
count: {count}
- - -
- ) - } - } -) -``` -::: - -`createSlice` 函数参数解读 - -- `name` 标记Slice 展示在dev-tool中 -- `initialState` 初始化状态 -- `reducers` 对象 对应之前的reducer函数 -- 返回值: 一个对象 包含所有actions - -`configureStore` 解读 - -- `reducer` 将slice中的reducer组成一个对象,传入此参数 -- `middleware` 额外的中间件 - - RTK已经为我们集成了`redux-thunk`和`redux-devtool`两个中间件 -- `devTools` 布尔值 是否启用开发者工具 - -### 使用RTK执行异步dispatch - -实际场景中都是在组件中发起网络请求,并且将状态更新到Store中 - -之前的开发中,我们通过`redux-thunk`这个中间件,让dispatch中可以进行异步操作 - -ReduxToolkit默认已经给我们集成了Thunk相关的功能:`createAsyncThunk` - -下面我们使用RTK实现一下这个场景:在Profile中请求postList数据并保存在Store中,并展示出来 - -::: code-group -```tsx [postList.js] {4-8,20} -// store/features/postList.js -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' - -export const fetchPostList = createAsyncThunk('fetch/postList', async () => { - const url = 'https://jsonplaceholder.typicode.com/posts' - const data = await fetch(url).then((res) => res.json()) - return data -}) - -const postListSlice = createSlice({ - name: 'postList', - initialState: { - postList: [] - }, - reducers: { - setPostList(state, { payload }) { - state.postList = payload - } - }, - extraReducers: { - [fetchPostList.fulfilled]: (state, { payload }) => { - console.log('payload', payload) - state.postList = payload - }, - [fetchPostList.pending]: (state, { payload }) => { - console.log('fetchPostList.pending', payload) - }, - [fetchPostList.rejected]: (state, { payload }) => { - console.log('fetchPostList.rejected', payload) - } - } -}) - -export const { setPostList } = postListSlice.actions - -export default postListSlice.reducer -``` -```tsx [index.js] -// store/index.js -import { configureStore } from '@reduxjs/toolkit' -import counterReducer from './features/counter' -import postListReducer from './features/postList' - -export default configureStore({ - reducer: { - counter: counterReducer, - postList: postListReducer - } -}) -``` -```tsx [Profile.jsx] -// Profile.jsx -import React, { Component } from 'react' -import { connect } from 'react-redux' -import { fetchPostList } from '../store/features/postList' - -const mapStateToProps = (state) => ({ - postList: state.postList.postList -}) - -const mapDispatchToProps = (dispatch) => ({ - fetchPostList: () => dispatch(fetchPostList()) -}) - -export default connect( - mapStateToProps, - mapDispatchToProps -)( - class Profile extends Component { - render() { - return ( -
- Profile - -
    - {this.props.postList.map((item, index) => ( -
  • {item.title}
  • - ))} -
-
- ) - } - } -) -``` -::: - -当`createAsyncThunk`创建出来的action被dispatch时,会存在三种状态: - -- pending: action被发出,但是还没有最终的结果 -- fulfilled: 获取到最终的结果(有返回值的结果) -- rejected: 执行过程中又错误或者抛出了异常 - -我们可以在`createSlice`的`entraReducer`中监听这些结果,根据派发action后的状态添加不同的逻辑进行处理 - -除了上述的写法,还可以为`extraReducer`传入一个函数,函数接收一个`builder`作为参数,在函数体内添加不同的case来监听异步操作的结果: - -```ts -// postList.js -... -extraReducers: (builder) => { - builder.addCase(fetchPostList.fulfilled, (state, { payload }) => { - state.postList = payload - }) - builder.addCase(fetchPostList.pending, (state, { payload }) => { - console.log('fetchPostList.pending', payload) - }) - builder.addCase(fetchPostList.rejected, (state, { payload }) => { - console.log('fetchPostList.rejected', payload) - }) -} -... -``` - -在之前的代码中,我们都是通过触发action后置的回调来更新state,那么有没有可能在请求完毕时确定性地更新store中的state? - -可以当请求有结果了,在请求成功的回调中直接dispatch设置state的action - -当我们通过dispatch触发异步action时可以传递额外的参数,这些参数可以在传入createAsyncThunk的回调函数的参数中获取到,同时也可以从函数的参数中获取到`dispatch`与`getState`函数,这样就可以在请求到数据后直接通过派发action的方式更新store中的state,下面是修改后的例子: - -```ts {6} -// postList.js -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' - -export const fetchPostList = createAsyncThunk( - 'fetch/postList', - async (extraInfo, { dispatch, getState }) => { - const url = 'https://jsonplaceholder.typicode.com/posts' - const data = await fetch(url).then((res) => res.json()) - dispatch(setPostList(data)) - } -) - -const postListSlice = createSlice({ - name: 'postList', - initialState: { - postList: [] - }, - reducers: { - setPostList(state, { payload }) { - state.postList = payload - } - } -}) - -export const { setPostList } = postListSlice.actions - -export default postListSlice.reducer -``` - -当然,此时异步action的状态已经不那么重要了,也就不必再`return data`了,除非你需要对异常状态做额外处理,仍然可以在`extraReducers`中添加异常处理回调 - -### Redux Toolkit的数据不可变性 - -Redux Toolkit本质是对之前繁琐的操作进行的一次封装 - -我们注意到:在之前reducer对state进行更新时,必须返回一个新的state才能触发修改`state = { ...state, count: count + 1 }`,但是经过Redux Toolkit的封装,我们只需要`state.count += 1`,直接对状态进行赋值就可以完成状态的更新 - -这是因为在RTK内部使用了`immutable.js`,数据不可变性 - -- 在React开发中,我们总是强调数据的不可变性 - - 无论是类组件中的state还是redux中管理的state - - JS的编码过程里,数据的不可变性都是非常重要的 -- 所以在之前我们更新state时都是通过浅拷贝来完成的 - - 但是浅拷贝也存在它的缺陷: - - 当对象过大时,进行浅拷贝会造成性能的浪费 - - 浅拷贝后的新对象,其深层属性仍然是旧对象的引用 - -Redux Toolkit底层使用了`immerjs`库来保证数据的不可变性 - -immutablejs库的底层原理和使用方法:[React系列十八 - Redux(四)state如何管理](https://mp.weixin.qq.com/s/hfeCDCcodBCGS5GpedxCGg) - -为了节约内存,出现了新的算法`Persistent Data Structure`持久化数据结构/一致性数据结构 - -- 用一种数据结构来保存数据 -- 当数据被修改时,会返回一个新的对象,但是新的对象会尽可能复用之前的数据结构而不会对内存进行浪费 -- 比如有一棵引用层级较深的树,当我们对其深层某个节点进行修改时,不会完全拷贝整棵树,而是在尽可能复用旧树结构的同时创建一棵新的树 - -一图胜千言: - -![immutable](./React.assets/immutable.gif) - -### connect的实现原理 - -connect函数是`react-redux`提供的一个高阶函数,它返回一个高阶组件,用于将store中的state/dispatch映射为组件的props - -下面一步一步手写一个connect函数,实现和库提供的connect一样的映射功能: - -首先完成基本的代码搭建,connect函数接收两个参数`mapStateToProps` `mapDispatchToProps`返回一个高阶组件 - -所谓高阶组件,就是传入一个类组件,返回一个增强后的新的类组件: - -```tsx -// connect.js -import { PureComponent } from 'react' - -export default function connect(mapStateToProps, mapDispatchToProps) { - return (WrapperComponent) => - class InnerComponent extends PureComponent { - render() { - return - } - } -} -``` - -其中,`mapStateToProps`和`mapDispatchToProps`都是函数,函数入参是`state`与`dispatch`,返回一个对象,键值对对应`prop <=> state/dispatch调用` - -我们导入store,并且从store中获取到state和dispatch传入`mapStateToProps`和`mapDispatchToProps`,随后将得到的键值对以props形式传递给WrapperComponent - -这样新组件就可以拿到这些状态与dispatch方法,我们可以在`componentDidMount`中监听整个store,当store中的状态发生改变时,强制执行re-render - -```tsx {9} -// connect.js -import { PureComponent } from 'react' -import store from '../store' - -export default function connect(mapStateToProps, mapDispatchToProps) { - return (WrapperComponent) => - class InnerComponent extends PureComponent { - componentDidMount() { - store.subscribe(() => this.forceUpdate()) - } - - render() { - const state = mapStateToProps(store.getState()) - const dispatch = mapDispatchToProps(store.dispatch) - return - } - } -} -``` - -上述代码能够正常工作,但是显然每次store内state发生改变都re-render是不明智的,因为组件可能只用到了store中的某些状态 - -那些组件没有用到的其他状态发生改变时,组件不应该也跟着re-render,这里可以做一些优化 - -```ts {10,14-16} -// connect.js -import { PureComponent } from 'react' -import store from '../store' - -export default function connect(mapStateToProps, mapDispatchToProps) { - return (WrapperComponent) => - class InnerComponent extends PureComponent { - constructor(props) { - super(props) - this.state = mapStateToProps(store.getState()) - } - - componentDidMount() { - store.subscribe(() => { - this.setState(mapStateToProps(store.getState())) - }) - } - - render() { - const state = mapStateToProps(store.getState()) - const dispatch = mapDispatchToProps(store.dispatch) - - return - } - } -} -``` - -经过优化后,每次store.state发生变化会触发setState,由React内部的机制来决定组件是否应当重新渲染 - -如果组件依赖的state发生变化了,那么React会替我们执行re-render,而不是每次都强制执行re-render - -进一步地,我们可以补充更多细节: - -- 当组件卸载时解除监听 - - `store.subscribe`会返回一个`unsubscribe`函数 用于解除监听 -- 解除与业务代码store的耦合 - - 目前的store来自业务代码 更优的做法是从context中动态获取到store - - 应当提供一个context Provider供用户使用 - - 就像`react-redux`一样,使用connect前需要将App用Provider包裹并传入store - -至此就基本完成了一个connect函数 - -::: code-group -```tsx [connect.js] -// connect.js -import { PureComponent } from 'react' -import { StoreContext } from './storeContext' - -export function connect(mapStateToProps, mapDispatchToProps) { - return (WrapperComponent) => { - class InnerComponent extends PureComponent { - constructor(props, context) { - super(props) - this.state = mapStateToProps(context.getState()) - } - - componentDidMount() { - this.unsubscribe = this.context.subscribe(() => { - this.setState(mapStateToProps(this.context.getState())) - }) - } - - componentWillUnmount() { - this.unsubscribe() - } - - render() { - const state = mapStateToProps(this.context.getState()) - const dispatch = mapDispatchToProps(this.context.dispatch) - - return - } - } - - InnerComponent.contextType = StoreContext - - return InnerComponent - } -} -``` -```tsx [storeContext.js] -// storeContext.js -import { createContext } from 'react' - -export const StoreContext = createContext(null) - -export const Provider = StoreContext.Provider -``` -```tsx [App.jsx] -// App.jsx -import React, { Component } from 'react' -import store from './store' -import Counter from './cpns/Counter' -import { Provider } from './hoc' - -export default class App extends Component { - render() { - return ( - -
-

React Redux

- -
-
- ) - } -} -``` -::: - -### 实现日志中间件logger - -设想现在有需求:设计一个Redux中间件,当我们每次通过dispatch派发action时都能够在控制台输出:派发了哪个action,传递的数据是怎样的 - -最终实现:拦截dispatch,并且在控制台打印派发的action - -```ts -// logger.js -function logger(store) { - const next = store.dispatch // 保留原始的dispatch函数 - function dispatchWithLog(action) { - console.group(action.type) - console.log('dispatching', action) - const res = next.dispatch(action) - console.log('next state', store.getState()) - console.groupEnd() - return res - } - store.dispatch = dispatchWithLog -} - -logger(store) // 应用中间件 -``` - -通过`monkey patch`对原始dispatch函数进行了修改,为函数添加额外的副作用 - -### 实现redux-thunk - -`redux-thunk`这个库帮我们提供了派发异步函数的功能 - -回顾一下`redux-thunk`的功能: - -- 默认情况下dispatch(action)的action必须为一个JS对象 -- redux-thunk允许我们传入一个函数作为action -- 函数会被调用,并且将dispatch函数和getState函数作为入参传递给这个函数action - - dispatch 允许我们在这之后再次派发action - - getState 允许我们之后的一些操作依赖原来的状态,可以获取到之前的状态 - -```ts {7} -// thunk.js -function thunk(store) { - const next = store.dispatch - function dispatchWithThunk(action) { - if (typeof action === 'function') { - // pass dispatch and getState to the thunk - return action(store.dispatch, store.getState) - } - return next(action) - } - return dispatchWithThunk -} - -thunk(store) // 应用中间件 -``` - -需要注意的是,传递给函数action的第一个参数是经过更新后的新的`dispatch`函数,这是从细节考虑:如果在函数中又派发了函数 - -### 实现applyMiddleware - -当我们需要同时应用多个中间件时,可以用`applyMiddleware`来对多个中间件进行组合,统一进行注册 - -```ts -// applyMiddleware.js -function applyMiddleware(store, ...fns) { - fns.forEach(fn => fn(store)) -} - -applyMiddleware(store, logger, thunk) // 使用applyMiddleware -``` - -## React Router - -三大框架都有各自的路由实现 - -- Angular ngRouter -- React ReactRouter -- Vue VueRouter - -React Router在最近两年的版本更新较快,并且在最新的React Router6发生了较大的变化 - -- Web开发只需要安装`react-router-dom` -- `react-router`包含一些ReactNative的内容 - -```bash -npm i react-router-dom -``` - -从`react-router-dom`中导出`BrowserRouter` 或 `HashRouter`,二者分别对应history模式与哈希模式 - -将App用二者之一包裹,即可启用路由: - -```tsx {5,10,12} -// index.js -import React, { StrictMode } from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import { HashRouter } from 'react-router-dom' - -const root = ReactDOM.createRoot(document.getElementById('root')) -root.render( - - - - - -) -``` - -路由的本质是路径与组件的映射关系(`path <==> component`) - -ReactRouter不像VueRouter,它的路由映射关系是书写在组件中的: - -下面的例子中使用到了几个**组件**:`Routes` `Route` `Navigate` `NavLink` - -- `Routes` `Route`用来描述路径与组件的映射关系 - - 通过为`path`和`element`传入路径和相对应的组件,将其包裹在`Routes`内即可完成路由的描述 -- `Navigate` 导航组件(在react-router5版本中是Redirect) - - 可以帮我们完成重定向操作,将想要重定向的路径传递给组件的`to`属性 - - **当组件出现时,就会自动执行跳转**,属于功能性组件 - - 当访问根路径`/`时就会自动跳转到`/home`页 -- `NavLink`用来实现路由的跳转 - - 特殊组件,其`className` `style`这些属性都可以传递一个函数 - - 可以从函数参数中解构出`isActive`属性来动态绑定样式(实际场景应用较少) - -```tsx -// App.js -import React, { PureComponent } from 'react' -import { Routes, Route, Navigate, NavLink } from 'react-router-dom' -import Home from './views/Home' -import About from './views/About' -import NotFound from './views/NotFound' - -export default class App extends PureComponent { - render() { - return ( -
-

App

- (isActive ? 'link-active' : '')}> - Home - - ({ color: isActive ? 'red' : '' })}> - About - - - }> - }> - }> - }> - -
- ) - } -} -``` - -另外,这里还有一个小技巧,在最末一个路由指定一个path为`*`的路由匹配规则,可以为路由匹配添加fallback策略,当未匹配到其之前的任何域名时,会展示NotFound页面 - -### 嵌套路由 - -嵌套路由可以通过在`Route`组件内部嵌套新的`Route`组件来实现 - -再通过`Outlet`组件来指定嵌套路由的占位元素(类似于VueRouter中的router-view) - -我们在之前的例子的基础上,为Home页面添加两个子页面HomeRanking和HomeRecommand - -同时,我们也应该为Home组件添加默认跳转,就像根路径默认重定向到Home组件那样,进入到Home组件后也应该默认重定向一个子页面中,这里我们仍然使用到了Navigate组件 - -::: code-group -```tsx [App.jsx ] -// App.jsx -import React, { PureComponent } from 'react' -import { Routes, Route, Navigate, NavLink } from 'react-router-dom' -import Home from './views/Home' -import HomeRanking from './views/HomeRanking' -import HomeRecommand from './views/HomeRecommand' - -export default class App extends PureComponent { - render() { - return ( -
- - }> - }> - }> - }> - - -
- ) - } -} -``` -```tsx [Home.jsx] -// Home.jsx -import React, { PureComponent } from 'react' -import { NavLink, Outlet } from 'react-router-dom' - -export default class Home extends PureComponent { - render() { - return ( -
-
Home
- Ranking - -
- ) - } -} -``` -::: - -### 编程式导航(高阶组件) - -之前使用的ReactRouter提供的路由跳转的组件,无论是`Link`还是`NavLink`可定制化能力都比较差,无法实现“点击按钮后跳转路由”这样的需求,那么我们就需要通过编程式导航,使用JS来完成路由的跳转 - -ReactRouter提供了编程式导航的API:`useNavigate` - -自ReactRouter6起,编程式导航的API不再支持ClassComponent,全面拥抱Hooks。 - -我们将在后续的学习中开启Hooks的写法,那么目前如何在类组件中也能使用Hooks呢?答案是高阶组件 - -封装一个高阶组件`withRouter`,经过高阶组件处理的类组件的props将会携带router对象,上面包含一些我们需要的属性和方法: - -::: code-group -```tsx [withRouter.js] -// withRouter.js -import { useNavigate } from 'react-router-dom' - -export function withRouter(WrapperComponent) { - return (props) => { - const navigate = useNavigate() - const router = { navigate } - return - } -} -``` -```tsx [Home.jsx] -// Home.jsx -import React, { PureComponent } from 'react' -import { Outlet } from 'react-router-dom' -import { withRouter } from '../hoc/withRouter' - -export default withRouter( - class Home extends PureComponent { - render() { - return ( -
-
Home
- - - -
- ) - } - } -) -``` -::: - -我们使用`withRouter`高阶组件对Home组件进行了增强,可以通过编程式导航来实现二级路由跳转 - -这里只是展示了编程式导航的用法和高阶组件的能力,目前还是尽可能使用Hooks写法编写新项目 - -### 动态路由(路由传参) - -传递参数由两种方式: - -- 动态路由的方式 -- 查询字符串传递参数 - -动态路由是指:路由中的**路径**信息并不会固定 - -- 比如匹配规则为`/detail/:id`时,`/detail/123` `detail/888`都会被匹配上,并将`123/888`作为id参数传递 -- 其中`/detail/:id`这个匹配规则被称为动态路由 - -动态路由常见于嵌套路由跳转,比如:从歌曲列表页面点击后跳转到歌曲详情页,可以通过路由传递歌曲的ID,访问到不同歌曲的详情页 - -我们在之前的HomeRanking榜单中加入列表和点击跳转功能,并编写一个新的组件Detail来接收来自路由的参数 - -同样地,`react-router-dom`为我们提供了从路由获取参数的API:`useParams`,它是一个Hooks,我们将它应用到之前编写的高级组件`withRouter`中 - -- 在使用了`withRouter`的组件中,就可以通过`this.props.router.params.xxx`获取到当前路由中传递的参数 -- 使用动态匹配路由时,传递给Route组件的`path`属性为`:xxx`,这里是`/detail/:id` - -::: code-group -```tsx [withRouter.js] -// withRouter.js -import { useNavigate, useParams } from 'react-router-dom' - -export function withRouter(WrapperComponent) { - return (props) => { - const navigate = useNavigate() - const params = useParams() - const router = { navigate, params } - return - } -} -``` -```tsx [HomeRanking.jsx] -// HomeRanking.jsx -import React, { PureComponent } from 'react' -import { withRouter } from '../hoc/withRouter' - -export default withRouter( - class HomeRanking extends PureComponent { - render() { - const list = Array.from(Array(10), (x, i) => ({ - id: ++i, - name: `Music ${i}` - })) - return ( -
-
HomeRanking
-
    - {list.map((item, index) => ( -
  • this.props.router.navigate(`/detail/${item.id}`)}> - {item.name} -
  • - ))} -
-
- ) - } - } -) -``` -```tsx [Detail.jsx] -// Detail.jsx -import React, { PureComponent } from 'react' -import { withRouter } from '../hoc/withRouter' - -export default withRouter( - class Detail extends PureComponent { - render() { - return ( -
-
Detail
- Current Music ID: {this.props.router.params.id} -
- ) - } - } -) -``` -::: - -#### 查询字符串的参数 - -之前传递的是路径参数,那么查询字符串参数应该如何获取? - -可以通过`useLocation`这个Hooks拿到当前地址详细信息: - -```tsx -const location = useLocation() -location.search // ?name=ziu&age=18 -``` - -需要自行完成数据的解析,不太方便 - -还有一个Hooks:`useSearchParams`,可以在获取到查询字符串信息的同时帮我们解析成`URLSearchParams`对象 - -要从`URLSearchParams`类型的对象中取值,需要通过标准方法`get` - -```tsx -const [ searchParams, setSearchParams ] = useSearchParams() -searchParams.get('name') // 'ziu' -searchParams.get('age') // 18 -``` - -当然,我们在实际使用中也可以通过`Object.fromEntries`将它转为普通对象,这样我们使用`useSearchParams`来对之前编写的高阶组件`withRouter`做一次增强: - -```tsx {8,9} -// withRouter.js -import { useNavigate, useParams, useSearchParams } from 'react-router-dom' - -export function withRouter(WrapperComponent) { - return (props) => { - const navigate = useNavigate() - const params = useParams() - const [searchParams] = useSearchParams() - const query = Object.fromEntries(searchParams) - const router = { navigate, params, query } - return - } -} -``` - -::: tip -需要注意的是,这里的`useSearchParams`是一个Hooks的常见形态 - -它返回一个数组,数组的首位为值,数组的次位为改变值的方法 - -与对象解构不同的是,数组结构是对位解构:保证位置一致则值一致,命名随意 - -而对象解构恰恰相反,不必保证位置,而需要保证命名一致 -::: - -### 路由的配置方式 - -至此为止,路由的配置是耦合在`App.jsx`中的,我们可以将Routes这部分代码抽离出单独的组件,也可以通过配置的方式来完成路由映射关系的编写 - -- 在ReactRouter5版本中,我们可以将路由的映射规则写为JS对象,需要引入第三方库`react-router-config` -- 在ReactRouter6版本中,允许我们将其写为配置文件,不需要安装其他内容 - -6版本为我们提供了一个API:`useRoutes`,将我们编写的配置文件传入此函数,可以将其转化为之前编写的组件结构,本质上也是一种语法糖 - -需要注意的是,Hooks只能在函数式组件中使用,这里我们将App组件改用FunctionComponent书写了 - -::: code-group -```tsx [index.js] -// router/index.js -import { Navigate } from 'react-router-dom' -import Home from '../views/Home' -import HomeRanking from '../views/HomeRanking' -import HomeRecommand from '../views/HomeRecommand' -import About from '../views/About' -import Detail from '../views/Detail' -import NotFound from '../views/NotFound' - -export const routes = [ - { - path: '/', - element: - }, - { - path: '/home', - element: , - children: [ - { - path: '', - element: - }, - { - path: 'ranking', - element: - }, - { - path: 'recommand', - element: - } - ] - }, - { - path: '/about', - element: - }, - { - path: '/detail/:id', - element: - }, - { - path: '*', - element: - } -] -``` -```tsx [App.jsx] -import React from 'react' -import { NavLink, useRoutes } from 'react-router-dom' -import { routes } from './router' - -export default function App() { - return ( -
-

App

- (isActive ? 'link-active' : '')}> - Home - - ({ color: isActive ? 'red' : '' })}> - About - - {useRoutes(routes)} -
- ) -} -``` -::: - -### 懒加载 - -针对某些场景的首屏优化,我们可以根据路由对代码进行分包,只有需要访问到某些页面时才从服务器请求对应的JS代码块 - -可以使用`React.lazy(() => import( ... ))`对某些代码进行懒加载 - -结合之前使用到的配置式路由映射规则,我们使用懒加载对代码进行分包 - -```tsx {6,7,18,24} -// router/index.js -import { lazy } from 'react' -// import HomeRecommand from '../views/HomeRecommand' -// import About from '../views/About' - -const HomeRecommand = lazy(() => import('../views/HomeRecommand')) -const About = lazy(() => import('../views/About')) - -export const routes = [ - ... - { - path: '/home', - element: , - children: [ - ... - { - path: 'recommand', - element: - } - ] - }, - { - path: '/about', - element: - }, - ... -] -``` - -这时在终端执行`pnpm build`可以发现,构建产物为我们执行了分包,`About`和`HomeRecommand`这两个次级页面被打进了两个单独的包中 - -> 在Vue中默认为我们完成了代码分包,第三方包的代码都被打包到了`vendors`中,业务代码放到了单独的JS文件中 - -> 只有当我们访问到这些页面时,才会发起网络请求,请求这些次级页面的JS代码 - -然而如果你在react-app的构建产物`index.html`开启本地预览服务器,会发现切换到对应页面后项目会crash(本地开发也会crash) - -```bash -# 使用 serve 开启本地预览服务器 -pnpm add serve -g -serve -s build # 将 build 作为根目录 -``` - -这是因为React默认没有为异步组件做额外处理,我们需要使用`Suspense`组件来额外处理懒加载的组件 - -```tsx -// index.js -import React, { StrictMode, Suspense } from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import { HashRouter } from 'react-router-dom' - -const root = ReactDOM.createRoot(document.getElementById('root')) -root.render( - - - Loading...}> - - - - -) -``` - -当根组件内部有组件处于异步加载状态时,都会在页面上展示`Loading...`而不是崩溃掉 diff --git a/docs/note/React.assets/redux-async-action.svg b/docs/note/Redux.assets/redux-async-action.svg similarity index 100% rename from docs/note/React.assets/redux-async-action.svg rename to docs/note/Redux.assets/redux-async-action.svg diff --git a/docs/note/React.assets/redux-usage.svg b/docs/note/Redux.assets/redux-usage.svg similarity index 100% rename from docs/note/React.assets/redux-usage.svg rename to docs/note/Redux.assets/redux-usage.svg diff --git a/docs/note/Redux.md b/docs/note/Redux.md new file mode 100644 index 00000000..e3bb6824 --- /dev/null +++ b/docs/note/Redux.md @@ -0,0 +1,1135 @@ +# Redux + +- Redux的核心思想 +- Redux的基本使用 +- React结合Redux +- Redux的异步操作 +- redux-devtool +- reducer的模块拆分 + +## 理解JavaScript的纯函数 + +- 函数式编程中有一个非常重要的概念 **纯函数**,JavaScript符合函数式编程的范式,所以也有纯函数的概念 + - 在React开发中,纯函数被多次提及: + - React组件被要求像一个纯函数(为什么是像,因为还有类组件) + - Redux中有一个reducer的概念,同样是要求必须是一个纯函数 +- 掌握纯函数对于理解很多框架的设计都是有帮助的 + +一个纯函数必然具备以下特征: + +- 确定的输入一定产生确定的输出 +- 函数的执行过程中,不能产生副作用 + + +## 为什么需要Redux + +- JS需要管理的状态越来越多,越来越复杂 +- 状态不断发生变化之间又相互依赖,这要求视图层也能同步更新 +- React提供了自动更新视图的方法,但状态仍需要手动管理 +- Redux可以帮我们管理状态,提供了**可预测的状态管理** +- 框架无关,体积只有2KB大小 + +## Redux的核心理念 + +Redux的核心理念 Store + +- 定义一个统一的规范来操作数据,这样就可以做到对数据的跟踪 +- `list.push()` `list[0].age = 18` + +Redux的核心理念 Action + +- Redux要求:要修改数据,必须通过Action来修改 +- 所有数据的变化,必须通过派发(Patch)Action来更新 +- Action是一个普通的JS对象,用来描述此次更新的type与content +- `const action = { type: 'ADD_ITEM', item: { name: 'Ziu', age: 18 } }` + +Redux的核心理念 Reducer + +- 如何将Store和Action联系在一起? +- reducer是一个纯函数 +- 完成的工作就是:将传入的state和action结合起来,生成一个新的state +- `patch` => `reducer` => `newState` => `Store` + +## Redux Demo + +下例中,通过`createStore`创建了一个Store(已经不推荐了) + +- initialState用于在调用`createStore`时作为默认值传入`reducer` +- 后续每次`store.dispatch`都会调用`reducer` +- 通过`reducer`更新state中的数据 + +在React中,可以通过`store.subscribe`注册State变化的监听回调 + +- 当state发生变化时,通过调用`this.forceUpdate`触发组件的更新 +- 一般情况下,我们在`componentDidMount`注册监听回调,在`componentWillUnmount`解除监听 + +::: code-group +```tsx [App.jsx] +// App.jsx +import React, { PureComponent } from 'react' +import store from './store' + +export default class App extends PureComponent { + componentDidMount() { + // Subscribe to the store + store.subscribe(() => { + console.log('subscribe', store.getState()) + this.forceUpdate() + }) + } + + componentWillUnmount() { + store.unsubscribe() + } + + render() { + return ( +
+

App

+

Count: {store.getState().count}

+

Name: {store.getState().name}

+ + + +
+ ) + } +} +``` +```tsx [index.js] +// store/index.js +import { createStore } from 'redux' + +// The initial application state +// This is the same as the state argument we passed to the createStore function +const initialState = { + count: 0, + name: 'Ziu' +} + +// Reducer: a pure function that takes the previous state and an action, and returns the next state. +// (previousState, action) => newState +function reducer(state = initialState, action) { + console.log('reducer', state, action) + + switch (action.type) { + case 'INCREMENT': + // NOTE: Keep functions pure - do not mutate the original state. + // Desctructure the state object and return a **new object** with the updated count + // Instead of `return state.count++` + return { + ...state, + count: state.count + 1 + } + case 'DECREMENT': + return { + ...state, + count: state.count - 1 + } + case 'CHANGE_NAME': + return { + ...state, + name: action.name + } + default: + return state + } +} + +const store = createStore(reducer) + +export default store +``` +::: + +![redux-usage](./Redux.assets/redux-usage.svg) + +## 进一步封装 + +可以将耦合在一起的代码拆分到不同文件中 + +- 将`reducer`抽取出来`reducer.js`,简化`store/index.js`内容 +- 将`action.type`抽取为常量`constants.js`,使用时做导入,以保证一致性 +- 将`action`抽取出来`actionFactory.js`,用于外部dispatch时规范类型 + +::: code-group +```tsx [index.js] +// store/index.js +import { createStore } from 'redux' +import reducer from './reducer' + +const store = createStore(reducer) + +export default store +``` +```tsx [constants.js] +// constants.js +export const INCREMENT = 'INCREMENT' +export const DECREMENT = 'DECREMENT' +export const CHANGE_NAME = 'CHANGE_NAME' +``` +```tsx [reducer.js] +// reducer.js +import * as actionType from './constants' + +const initialState = { + count: 0, + name: 'Ziu' +} + +export default function reducer(state = initialState, action) { + switch (action.type) { + case actionType.INCREMENT: + return { + ...state, + count: state.count + 1 + } + case actionType.DECREMENT: + return { + ...state, + count: state.count - 1 + } + case actionType.CHANGE_NAME: + return { + ...state, + name: action.name + } + default: + return state + } +} +``` +```tsx [actionFactory.js] +// actionFactory.js +import * as actionType from './constants' + +export const increment = () => ({ + type: actionType.INCREMENT +}) + +export const decrement = () => ({ + type: actionType.DECREMENT +}) + +export const changeName = (name) => ({ + type: actionType.CHANGE_NAME, + name +}) +``` +::: + +```tsx +// App.jsx +import React, { PureComponent } from 'react' +import store from './store' +import { increment, decrement, changeName } from './store/actionFactory' + +export default class App extends PureComponent { + componentDidMount() { + store.subscribe(() => this.forceUpdate()) + } + componentWillUnmount() { + store.unsubscribe() + } + render() { + return ( +
+

App

+

Count: {store.getState().count}

+

Name: {store.getState().name}

+ + + +
+ ) + } +} +``` + +## Redux的三大原则 + +单一数据源 + +- 整个应用程序的状态都被存储在一棵Object Tree上 +- 且这个Object Tree只存储在一个Store中 +- 但Redux并不强制限制创建多Store,不利于数据维护 +- 单一数据源有利于整个应用程序的维护、追踪、修改 + +State属性是只读的 + +- 允许修改State的方法只有patch action,不要直接修改State +- 确保了View或网络请求都不能修改State +- 保证所有的修改都能被追踪、按照严格的顺序执行,不用担心竞态(race condition)的问题 + +使用纯函数来执行修改 + +- 通过reducer将旧State与新State联系在一起,并且返回一个**新的State** +- 随着应用程序复杂程度增加,可以将reducer拆分为多个小的reducer,分别用于操作不同State Tree的某一部分 +- 所有的reducer都应该是纯函数,不能产生任何的副作用 + +## 优化重复代码 + +当编写了一些案例的时候会发现,React结合Redux时会编写很多重复的代码 + +在每个需要用到Redux中状态的组件中,都需要在不同生命周期做添加订阅/解除订阅的处理,组件初始化时还要从store中取最新的状态 + +针对重复代码的问题,可以使用之前学到的高阶组件来做优化 + +Redux官方提供的库`react-redux`,可以让我们更方便的在React中使用Redux + +```bash +npm i react-redux +``` + +在Profile组件中,通过高阶函数`connect`实现的 + +将store中需要的状态通过`mapStoreToProps`转为props,并将需要使用store中状态的组件传入调用connect返回的函数中 + +在`Profile`组件中就可以从props中获取到store中的状态 + +::: code-group +```tsx [App.jsx] +// App.jsx +import React, { PureComponent } from 'react' +import { Provider } from 'react-redux' +import store from './store' +import Profile from './Profile' + +export default class App extends PureComponent { + render() { + return ( + +
+

App

+ +
+
+ ) + } +} +``` +```tsx [Profile.jsx] +// Profile.jsx +import React, { Component } from 'react' +import { connect } from 'react-redux' + +// mapStateToProps is a function that +// takes the state of the store as an argument +// and returns an object with the data that the component needs from the store. +// component will receive the data as props. +const mapStateToProps = (state) => ({ + count: state.count +}) + +export default connect(mapStateToProps)( + class Profile extends Component { + render() { + return ( +
+

Profile

+

Count: {this.props.count}

+
+ ) + } + } +) +``` +::: + +我们刚刚只是完成了对State的映射,将Store中保存的全局状态state映射到了Profile组件的props中 + +connect还可以传入第二个参数,用于将action也映射到props中: + +```tsx {4,10-13,17,25,26} +// Profile.jsx +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { INCREMENT, DECREMENT } from './store/constants' + +const mapStateToProps = (state) => ({ + count: state.count +}) + +const mapDispatchToProps = (dispatch) => ({ + increment: () => dispatch({ type: INCREMENT }), + decrement: () => dispatch({ type: DECREMENT }) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)( + class Profile extends Component { + render() { + return ( +
+

Profile

+

Count: {this.props.count}

+ + +
+ ) + } + } +) +``` + +本质上是`connect`内部对操作进行了封装,把逻辑隐藏起来了: + +- 调用`connect`这个**高阶函数**,返回一个**高阶组件** +- 为高阶组件传入映射目标组件,最后高阶组件返回一个新组件 +- 新组件的props包含了来自Store中状态/dispatch的映射 + +## 异步Action + +有些场景下,我们希望组件能够直接调用Store中的action来触发网络请求,并且获取到数据 + +但是dispatch只允许派发对象类型的Action,不能通过dispatch派发函数 + +可以通过中间件`redux-thunk`来对Redux做增强,让dispatch能够对函数进行派发 + +```bash +npm i redux-thunk +``` + +通过`applyMiddleware`引入`redux-thunk`这个中间件: + +::: code-group +```tsx [index.js] {2,3,6} +// store/index.js +import { createStore, applyMiddleware } from 'redux' +import thunk from 'redux-thunk' +import reducer from './reducer' + +const store = createStore(reducer, applyMiddleware(thunk)) + +export default store +``` +```tsx [actionFactory.js] +// actionFactory.js +export const fetchPostList = () => { + return (dispatch, getState) => { + fetch('https://jsonplaceholder.typicode.com/posts') + .then((res) => res.json()) + .then((res) => { + dispatch({ + type: actionType.FETCH_POST_LIST, + list: res + }) + }) + } +} +``` +```tsx [list.jsx] +// list.jsx +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { fetchPostList } from './store/actionFactory' + +const mapStateToProps = (state) => ({ + list: state.list +}) + +const mapDispatchToProps = (dispatch) => ({ + fetchList: () => dispatch(fetchPostList()) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)( + class Profile extends Component { + render() { + return ( +
+

List

+ + {this.props.list.length && ( +
    + {this.props.list.map((item) => ( +
  • {item.title}
  • + ))} +
+ )} +
+ ) + } + } +) +``` +::: + +- 这样就可以将网络请求的具体逻辑代码隐藏到Redux中 +- 将网络请求归于状态管理的一部分 +- 而不是书写在组件内,不利于维护,耦合度太高 + +`redux-thunk`是如何做到可以让我们发送异步请求的? + +- 默认情况下`dispatch(action)`的action必须为一个JS对象 +- `redux-thunk`允许我们传入一个函数作为`action` +- 函数会被调用,并且将`dispatch`函数和`getState`函数作为入参传递给这个函数action + - `dispatch` 允许我们在这之后再次派发`action` + - `getState` 允许我们之后的一些操作依赖原来的状态,可以获取到之前的状态 + +下图展示了从组件调用方法,触发patch到Redux接收patch、发送网络请求、更新state的全过程: + +![redux-async-action](./Redux.assets/redux-async-action.svg) + +## 拆分Store + +拆分Store带来的益处很多,便于多人协作、不同业务逻辑解耦等 + +在Redux中,拆分Store的本质是拆分不同的`reducer`函数,之前在使用`createStore`时,传入的就是`reducer`函数 + +之前的Store写法与用法: + +```tsx {8-9} +// store/index.js +import { createStore } from 'redux' +import reducer from './reducer' + +const store = createStore(reducer) + +// App.jsx +store.getState().count +store.getState().list +``` + +拆分Store后的写法与用法: + +```tsx {7-8,14-15} +// store/index.js +import { createStore, combineReducers } from 'redux' +import counterReducer from './counter' +import postListReducer from './postList' + +const reducer = combineReducers({ + counter: counterReducer, + postList: postListReducer +}) + +const store = createStore(reducer) + +// App.jsx +store.getState().counter.count +store.getState().postList.count +``` + +拆分为多个Reducer之后,需要首先`getState()`获取到整个状态树,随后指定获取到不同的模块中的状态 + +拆分后,不同模块下的文件是保持一致的: + +```sh +- store/ # Store根目录 + - index.js # 导出 store 位置 + - counter/ # Counter模块 + - actionFactory.js + - constants.js + - index.js # 统一导出 + - reducer.js + - postList/ # PostList模块 + - actionFactory.js + - constants.js + - index.js + - reducer.js + - ... +``` + +### combineReducer函数 + +前面拆分Store时用到了`combineReducer`函数,将多个模块reducer组合到一起,函数内部是如何处理的? + +- 将传入的reducers合并到一个对象中,最终返回一个`combination`函数(相当于未拆分时传给`createStore`的`reducer`函数) +- 在执行`combination`函数的过程中,它会通过判断前后返回的数据是否相同来决定返回之前的state还是新的state +- 新state会触发订阅者发生对应更新,而旧state可以有效地组织订阅者发生刷新 + +下面简单写了一下`combineReducer`的实现原理 + +```ts +// 使用 +combineReducer({ + counter: combineReducer, + postList: postListReducer +}) + +// 创建一个新的reducer +function reducer(state = {}, action) { + // 返回一个对象 是Store的state + return { + counter: counterReducer(state.counter, action), + postList: postListReducer(state.postList, action) + } +} +``` + +## ReduxToolkit + +- ReduxToolkit重构 +- ReduxToolkit异步 +- connect高阶组件 +- 中间件的实现原理 +- React状态管理选择 + +## 认识ReduxToolkit + +之前在使用`createStore`创建Store时会出现deprecated标识,推荐我们使用`@reduxjs/toolkit`包中的`configureStore`函数 + +Redux Toolkit是官方推荐编写Redux逻辑的方法 + +- 在前面学习Redux时已经发现,Redux的逻辑编写过于繁琐、麻烦 +- 代码分拆在不同模块中,存在大量重复代码 +- Redux Toolkit旨在成为编写Redux逻辑的标准方式,从而解决上面提到的问题 +- 这个包常被称为:RTK + +## 使用ReduxToolkit重写Store + +Redux Toolkit依赖于react-redux包,所以需要同时安装这二者 + +```bash +npm i @reduxjs/toolkit react-redux +``` + +Redux Toolkit的核心API主要是下述几个: + +- `configureStore` 包装createStore以提供简化的配置选项和良好的默认值 + - 可以自动组合你的slice reducer 添加你提供的任何Redux中间件 + - 默认包含redux-thunk,并启用Redux DevTools Extension +- `createSlice` 创建切片 片段 + - 接受reducer函数的对象、切片名称和初始状态值,并自动生成切片reducer,并带有actions +- `createAsyncThunk` + - 接受一个动作类型字符串和一个返回Promise的函数 + - 并生成一个`pending / fullfilled / rejected`基于该承诺分派动作类型的thunk + +写一个Demo: + +::: code-group +```tsx [index.js] +// store/index.js +import { configureStore } from '@reduxjs/toolkit' +import counterSlice from './features/counter' + +const store = configureStore({ + reducer: { + counter: counterSlice + } +}) + +export default store +``` +```tsx [counter.js] +// store/features/counter.js +import { createSlice } from '@reduxjs/toolkit' + +const counterSlice = createSlice({ + name: 'counter', + initialState: { + count: 0 + }, + reducers: { + addCount(state, action) { + const { payload } = action + state.count += payload + }, + subCount(state, action) { + const { payload } = action + state.count -= payload + } + } +}) + +const { actions, reducer } = counterSlice + +export const { addCount, subCount } = actions + +export default reducer +``` +```tsx [Counter.jsx] +// Counter.jsx +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { addCount, subCount } from '../store/features/counter' + +const mapStateToProps = (state) => ({ + count: state.counter.count +}) + +const mapDispatchToProps = (dispatch) => ({ + increment: (count) => { + const action = addCount(count) + return dispatch(action) + }, + decrement: (count) => { + const action = subCount(count) + return dispatch(action) + } +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)( + class Counter extends Component { + render() { + const { count } = this.props + + return ( +
+

Counter

+
count: {count}
+ + +
+ ) + } + } +) +``` +::: + +`createSlice` 函数参数解读 + +- `name` 标记Slice 展示在dev-tool中 +- `initialState` 初始化状态 +- `reducers` 对象 对应之前的reducer函数 +- 返回值: 一个对象 包含所有actions + +`configureStore` 解读 + +- `reducer` 将slice中的reducer组成一个对象,传入此参数 +- `middleware` 额外的中间件 + - RTK已经为我们集成了`redux-thunk`和`redux-devtool`两个中间件 +- `devTools` 布尔值 是否启用开发者工具 + +## 使用RTK执行异步dispatch + +实际场景中都是在组件中发起网络请求,并且将状态更新到Store中 + +之前的开发中,我们通过`redux-thunk`这个中间件,让dispatch中可以进行异步操作 + +ReduxToolkit默认已经给我们集成了Thunk相关的功能:`createAsyncThunk` + +下面我们使用RTK实现一下这个场景:在Profile中请求postList数据并保存在Store中,并展示出来 + +::: code-group +```tsx [postList.js] {4-8,20} +// store/features/postList.js +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' + +export const fetchPostList = createAsyncThunk('fetch/postList', async () => { + const url = 'https://jsonplaceholder.typicode.com/posts' + const data = await fetch(url).then((res) => res.json()) + return data +}) + +const postListSlice = createSlice({ + name: 'postList', + initialState: { + postList: [] + }, + reducers: { + setPostList(state, { payload }) { + state.postList = payload + } + }, + extraReducers: { + [fetchPostList.fulfilled]: (state, { payload }) => { + console.log('payload', payload) + state.postList = payload + }, + [fetchPostList.pending]: (state, { payload }) => { + console.log('fetchPostList.pending', payload) + }, + [fetchPostList.rejected]: (state, { payload }) => { + console.log('fetchPostList.rejected', payload) + } + } +}) + +export const { setPostList } = postListSlice.actions + +export default postListSlice.reducer +``` +```tsx [index.js] +// store/index.js +import { configureStore } from '@reduxjs/toolkit' +import counterReducer from './features/counter' +import postListReducer from './features/postList' + +export default configureStore({ + reducer: { + counter: counterReducer, + postList: postListReducer + } +}) +``` +```tsx [Profile.jsx] +// Profile.jsx +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { fetchPostList } from '../store/features/postList' + +const mapStateToProps = (state) => ({ + postList: state.postList.postList +}) + +const mapDispatchToProps = (dispatch) => ({ + fetchPostList: () => dispatch(fetchPostList()) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)( + class Profile extends Component { + render() { + return ( +
+ Profile + +
    + {this.props.postList.map((item, index) => ( +
  • {item.title}
  • + ))} +
+
+ ) + } + } +) +``` +::: + +当`createAsyncThunk`创建出来的action被dispatch时,会存在三种状态: + +- pending: action被发出,但是还没有最终的结果 +- fulfilled: 获取到最终的结果(有返回值的结果) +- rejected: 执行过程中又错误或者抛出了异常 + +我们可以在`createSlice`的`entraReducer`中监听这些结果,根据派发action后的状态添加不同的逻辑进行处理 + +除了上述的写法,还可以为`extraReducer`传入一个函数,函数接收一个`builder`作为参数,在函数体内添加不同的case来监听异步操作的结果: + +```ts +// postList.js +... +extraReducers: (builder) => { + builder.addCase(fetchPostList.fulfilled, (state, { payload }) => { + state.postList = payload + }) + builder.addCase(fetchPostList.pending, (state, { payload }) => { + console.log('fetchPostList.pending', payload) + }) + builder.addCase(fetchPostList.rejected, (state, { payload }) => { + console.log('fetchPostList.rejected', payload) + }) +} +... +``` + +在之前的代码中,我们都是通过触发action后置的回调来更新state,那么有没有可能在请求完毕时确定性地更新store中的state? + +可以当请求有结果了,在请求成功的回调中直接dispatch设置state的action + +当我们通过dispatch触发异步action时可以传递额外的参数,这些参数可以在传入createAsyncThunk的回调函数的参数中获取到,同时也可以从函数的参数中获取到`dispatch`与`getState`函数,这样就可以在请求到数据后直接通过派发action的方式更新store中的state,下面是修改后的例子: + +```ts {6} +// postList.js +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' + +export const fetchPostList = createAsyncThunk( + 'fetch/postList', + async (extraInfo, { dispatch, getState }) => { + const url = 'https://jsonplaceholder.typicode.com/posts' + const data = await fetch(url).then((res) => res.json()) + dispatch(setPostList(data)) + } +) + +const postListSlice = createSlice({ + name: 'postList', + initialState: { + postList: [] + }, + reducers: { + setPostList(state, { payload }) { + state.postList = payload + } + } +}) + +export const { setPostList } = postListSlice.actions + +export default postListSlice.reducer +``` + +当然,此时异步action的状态已经不那么重要了,也就不必再`return data`了,除非你需要对异常状态做额外处理,仍然可以在`extraReducers`中添加异常处理回调 + +## Redux Toolkit的数据不可变性 + +Redux Toolkit本质是对之前繁琐的操作进行的一次封装 + +我们注意到:在之前reducer对state进行更新时,必须返回一个新的state才能触发修改`state = { ...state, count: count + 1 }`,但是经过Redux Toolkit的封装,我们只需要`state.count += 1`,直接对状态进行赋值就可以完成状态的更新 + +这是因为在RTK内部使用了`immutable.js`,数据不可变性 + +- 在React开发中,我们总是强调数据的不可变性 + - 无论是类组件中的state还是redux中管理的state + - JS的编码过程里,数据的不可变性都是非常重要的 +- 所以在之前我们更新state时都是通过浅拷贝来完成的 + - 但是浅拷贝也存在它的缺陷: + - 当对象过大时,进行浅拷贝会造成性能的浪费 + - 浅拷贝后的新对象,其深层属性仍然是旧对象的引用 + +Redux Toolkit底层使用了`immerjs`库来保证数据的不可变性 + +immutablejs库的底层原理和使用方法:[React系列十八 - Redux(四)state如何管理](https://mp.weixin.qq.com/s/hfeCDCcodBCGS5GpedxCGg) + +为了节约内存,出现了新的算法`Persistent Data Structure`持久化数据结构/一致性数据结构 + +- 用一种数据结构来保存数据 +- 当数据被修改时,会返回一个新的对象,但是新的对象会尽可能复用之前的数据结构而不会对内存进行浪费 +- 比如有一棵引用层级较深的树,当我们对其深层某个节点进行修改时,不会完全拷贝整棵树,而是在尽可能复用旧树结构的同时创建一棵新的树 + +一图胜千言: + +![immutable](./React.assets/immutable.gif) + +## connect的实现原理 + +connect函数是`react-redux`提供的一个高阶函数,它返回一个高阶组件,用于将store中的state/dispatch映射为组件的props + +下面一步一步手写一个connect函数,实现和库提供的connect一样的映射功能: + +首先完成基本的代码搭建,connect函数接收两个参数`mapStateToProps` `mapDispatchToProps`返回一个高阶组件 + +所谓高阶组件,就是传入一个类组件,返回一个增强后的新的类组件: + +```tsx +// connect.js +import { PureComponent } from 'react' + +export default function connect(mapStateToProps, mapDispatchToProps) { + return (WrapperComponent) => + class InnerComponent extends PureComponent { + render() { + return + } + } +} +``` + +其中,`mapStateToProps`和`mapDispatchToProps`都是函数,函数入参是`state`与`dispatch`,返回一个对象,键值对对应`prop <=> state/dispatch调用` + +我们导入store,并且从store中获取到state和dispatch传入`mapStateToProps`和`mapDispatchToProps`,随后将得到的键值对以props形式传递给WrapperComponent + +这样新组件就可以拿到这些状态与dispatch方法,我们可以在`componentDidMount`中监听整个store,当store中的状态发生改变时,强制执行re-render + +```tsx {9} +// connect.js +import { PureComponent } from 'react' +import store from '../store' + +export default function connect(mapStateToProps, mapDispatchToProps) { + return (WrapperComponent) => + class InnerComponent extends PureComponent { + componentDidMount() { + store.subscribe(() => this.forceUpdate()) + } + + render() { + const state = mapStateToProps(store.getState()) + const dispatch = mapDispatchToProps(store.dispatch) + return + } + } +} +``` + +上述代码能够正常工作,但是显然每次store内state发生改变都re-render是不明智的,因为组件可能只用到了store中的某些状态 + +那些组件没有用到的其他状态发生改变时,组件不应该也跟着re-render,这里可以做一些优化 + +```ts {10,14-16} +// connect.js +import { PureComponent } from 'react' +import store from '../store' + +export default function connect(mapStateToProps, mapDispatchToProps) { + return (WrapperComponent) => + class InnerComponent extends PureComponent { + constructor(props) { + super(props) + this.state = mapStateToProps(store.getState()) + } + + componentDidMount() { + store.subscribe(() => { + this.setState(mapStateToProps(store.getState())) + }) + } + + render() { + const state = mapStateToProps(store.getState()) + const dispatch = mapDispatchToProps(store.dispatch) + + return + } + } +} +``` + +经过优化后,每次store.state发生变化会触发setState,由React内部的机制来决定组件是否应当重新渲染 + +如果组件依赖的state发生变化了,那么React会替我们执行re-render,而不是每次都强制执行re-render + +进一步地,我们可以补充更多细节: + +- 当组件卸载时解除监听 + - `store.subscribe`会返回一个`unsubscribe`函数 用于解除监听 +- 解除与业务代码store的耦合 + - 目前的store来自业务代码 更优的做法是从context中动态获取到store + - 应当提供一个context Provider供用户使用 + - 就像`react-redux`一样,使用connect前需要将App用Provider包裹并传入store + +至此就基本完成了一个connect函数 + +::: code-group +```tsx [connect.js] +// connect.js +import { PureComponent } from 'react' +import { StoreContext } from './storeContext' + +export function connect(mapStateToProps, mapDispatchToProps) { + return (WrapperComponent) => { + class InnerComponent extends PureComponent { + constructor(props, context) { + super(props) + this.state = mapStateToProps(context.getState()) + } + + componentDidMount() { + this.unsubscribe = this.context.subscribe(() => { + this.setState(mapStateToProps(this.context.getState())) + }) + } + + componentWillUnmount() { + this.unsubscribe() + } + + render() { + const state = mapStateToProps(this.context.getState()) + const dispatch = mapDispatchToProps(this.context.dispatch) + + return + } + } + + InnerComponent.contextType = StoreContext + + return InnerComponent + } +} +``` +```tsx [storeContext.js] +// storeContext.js +import { createContext } from 'react' + +export const StoreContext = createContext(null) + +export const Provider = StoreContext.Provider +``` +```tsx [App.jsx] +// App.jsx +import React, { Component } from 'react' +import store from './store' +import Counter from './cpns/Counter' +import { Provider } from './hoc' + +export default class App extends Component { + render() { + return ( + +
+

React Redux

+ +
+
+ ) + } +} +``` +::: + +## 实现日志中间件logger + +设想现在有需求:设计一个Redux中间件,当我们每次通过dispatch派发action时都能够在控制台输出:派发了哪个action,传递的数据是怎样的 + +最终实现:拦截dispatch,并且在控制台打印派发的action + +```ts +// logger.js +function logger(store) { + const next = store.dispatch // 保留原始的dispatch函数 + function dispatchWithLog(action) { + console.group(action.type) + console.log('dispatching', action) + const res = next.dispatch(action) + console.log('next state', store.getState()) + console.groupEnd() + return res + } + store.dispatch = dispatchWithLog +} + +logger(store) // 应用中间件 +``` + +通过`monkey patch`对原始dispatch函数进行了修改,为函数添加额外的副作用 + +## 实现redux-thunk + +`redux-thunk`这个库帮我们提供了派发异步函数的功能 + +回顾一下`redux-thunk`的功能: + +- 默认情况下dispatch(action)的action必须为一个JS对象 +- redux-thunk允许我们传入一个函数作为action +- 函数会被调用,并且将dispatch函数和getState函数作为入参传递给这个函数action + - dispatch 允许我们在这之后再次派发action + - getState 允许我们之后的一些操作依赖原来的状态,可以获取到之前的状态 + +```ts {7} +// thunk.js +function thunk(store) { + const next = store.dispatch + function dispatchWithThunk(action) { + if (typeof action === 'function') { + // pass dispatch and getState to the thunk + return action(store.dispatch, store.getState) + } + return next(action) + } + return dispatchWithThunk +} + +thunk(store) // 应用中间件 +``` + +需要注意的是,传递给函数action的第一个参数是经过更新后的新的`dispatch`函数,这是从细节考虑:如果在函数中又派发了函数 + +## 实现applyMiddleware + +当我们需要同时应用多个中间件时,可以用`applyMiddleware`来对多个中间件进行组合,统一进行注册 + +```ts +// applyMiddleware.js +function applyMiddleware(store, ...fns) { + fns.forEach(fn => fn(store)) +} + +applyMiddleware(store, logger, thunk) // 使用applyMiddleware +```