Redux异步数据流与性能优化
徐徐 抱歉选手

异步逻辑

Redux store本身是不能处理异步逻辑的。为了使异步逻辑与Redux store产生交互,引入Redux middleware。

The most common reason to use middleware is to allow different kinds of async logic to interact with the store.

最常用的异步逻辑中间件就是redux-thunk。

使用thunk前的准备

为了使用thunk,首先要让redux store支持thunk middleware。由于Redux Toolkit的configureStore API自动配置好了thunk middleware,所以将thunk作为用redux书写异步逻辑的标准形式。

ReduxAsyncDataFlowDiagram-d97ff38a0f4da0f327163170ccc13e80

thunk的特质以及手写thunk

能够在store中调用thunk

Once the thunk middleware has been added to the Redux store, it allows you to pass thunk functions directly to store.dispatch.

能够dispatch plain action creators

A thunk function will always be called with (dispatch, getState) as its arguments, and you can use them inside the thunk as needed.

手写thunk action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// my-app/src/app/store.js

const store = configureStore({ reducer: counterReducer })

const exampleThunkFunction = (dispatch, getState) => {
const stateBefore = getState()
// 能够和其他reducer一样读取store中的状态/数据
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment())
// dispatch的是一个action creator
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}

store.dispatch(exampleThunkFunction)

手写thunk action creators

类比普通的action和action creators,把箭头函数的部分放到return语句中去,并且将参数传入整体creators。这样的一个好处就是可以给thunk action中要dispatch的action传入参数。

1
2
3
4
5
6
7
8
9
10
11
const logAndAdd = amount => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}

store.dispatch(logAndAdd(5))

但在实际写代码的过程中,会把thunk action/thunk action creators放在slice文件中,而我们在书写slice时使用的createSlice本身是不支持定义thunks的,因此他们作为独立的函数和createSlice一同存在于slice文件中。

使用thunk对server API进行AJAX calls

request的阶段与手写thunk

以下三个步骤不是必须的,但都是commonly used。

  • a “start” action is dispatched before the requst, to indicate that the request is in progress.常用于追踪loading状态,然后让UI同时显示在加载中。
  • The async request is made
  • Depending on the request result, the async logic dispatches either a “success” action containing the result data, or a “failure” action containing error details.

这三个步骤即相当于三个action,我们如果手写thunk就需要先定义好这三个步骤的type和error,最后用一个含有异步逻辑async/await的函数,以及try catch语句把这些步骤拼接起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = repoDetails => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = error => ({
type: 'repoDetails/fetchFailed',
error
})
const fetchIssuesCount = (org, repo) => async dispatch => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}

为什么不用Promise.then?而是使用async/await关键字呢?因为阶段的最后我们需要捕捉错误信息,也就是使用try/catch模块,而它不能Promise.then一起使用。


使用createAsyncThunk定义thunk

手写fetching data的问题在于每个action都要定义,或者说为每个action都定义action creators很麻烦。所以createAsyncThunk API抽象出了这三个步骤,最终产生了一个能够自动处理这三个步骤的thunk。

createAsyncThunk接受两个参数:

  • A string that will be used as the prefix for the generated action types

    本质上thunk function也是action creator,只不过是异步的。

  • A “payload creator” callback function that should return a Promise containing some data, or a rejected Promise with an error

    这部分承担了发出AJAX请求的工作,返回的是Promise from the AJAX call,或者是some data from the API response。

    一般使用JS async/await关键字,而不是一般的somePromise.then()语法,为了能够结合try/catch逻辑一起使用。

使用createAsyncThunk API创建thunk function的实例。在使用createAsyncThunk创建hunk function之后,该函数会返回pending/fulfilled/rejected三种action creators(对应到上一个章节就是request的三个阶段),这个在后续与createSlice中extraReducer的互动中会使用到。

1
2
3
4
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.posts
})

tracking request states

总共有四个可能的请求状态。

When we make an API call, we can view its progress as a small state machine that can be in one of four possible states:

  • The request hasn’t started yet
  • The request is in progress
  • The request succeeded, and we now have the data we need
  • The request failed, and there’s probably an error message
1
2
3
4
5
{
status: 'idle' | 'loading' | 'succeeded' | 'failed',
// 名称都是可以自己定义的,例如pending=loading/complete=succeeded。
error: string | null
}

使用createAsyncThunk自动产生的三个action creators来显示request的状态。

createAsyncThunk accepts a “payload creator” callback that should return a Promise, and generates pending/fulfilled/rejected action types automatically。

上面这段话的意思应该是虽然createAsyncThunk会自动产生action type,但是这个action的具体内容,他的行为是需要我们去定义的。当AJAX calls在pending的状态时,我们希望在什么数据区域做出什么样的动作。这个数据区域可能就是某个feature中,也有可能是全局redux store中,更有可能是在UI component中。

Why tracking

一方面,这些状态信息信息可以背被UI使用,比如在loading的时候可以UI显示转圈圈;另一方面,这些meta data可以是我们的fetch data行为只进行一次,而不是在每一次该UI重新渲染的时候都rerender一下。

但是问题是request状态信息pending/fulfilled/rejected都是由thunk本身的promise对象保存着的。而这个信息要从slice流通到UI去,需要另外考虑。

How tracking

Thunk=>extraReducers=>initialState=>store=>UI component

createAsyncThunk写在slice文件中,同时slice文件中的initialState也需要有一个key status来指示数据从第三方API获得数据进展到了哪一步,这样的话我们就能决定UI在request进行的过程中该如何显示。

为了追踪dispatch AJAX calls的状态,也就是track AJAX calls states,一个好的模式就是在该slice的数据结构中有一个state section。也就是tracking request states in slices。

We could track the request status in slice file using a second loading enum if we wanted to.

定义部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// postsSlice中initialState的定义
const initialState = {
posts: [],
// 数据实体
status: 'idle',
// 这里的status更像是一个窗口,连通了thunk function和UI component
error: null
}
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.posts
})

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers: {
// 并不是所有的状态都需要考虑,忽略pending就是什么不都不做
[fetchPosts.pending]: (state, action) => {
state.status = 'loading'
},
[fetchPosts.fulfilled]: (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts = state.posts.concat(action.payload)
},
[fetchPosts.rejected]: (state, action) => {
state.status = 'failed'
state.error = action.error.message
}
}
})

调用部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//postList.js
export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)

const postStatus = useSelector(state => state.posts.status)

useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])

// omit rendering logic
}

Thunk=>UI component=>statusEnum=>try/catch/disptach

首先add a loading status enum field as a React useState hook,这个loading status enum就类似于之前slice中initialState对象中的status field。

1
2
3
4
// UI component
export const AddPostForm = () => {
// other datas
const [addRequestStatus, setAddRequestStatus] = useState('idle')

在try模块中,先将enum的状态设置为pending,然后开始dispatch thunk function,由于createAsyncThunk在函数内部处理错误,所以我们不能获取rejected message,这不利于catch模块输出错误信息。

createAsyncThunk handles any errors internally, so that we don’t see any messages about “rejected Promises” in our logs.

为了解决错误信息的问题,Redux Toolkit拥有一个功能函数,名为unwrapResult,他让dispatch后的thunk action creator有两种表现:如果是fulfilled action就返回action.payload,如果是rejected action就报错,这样就能在try/catch模块中使用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// UI component
const onSavePostClicked = async () => {
if (canSave) {
try {
setAddRequestStatus('pending')

const resultAction = await dispatch(
addNewPost({ title, content, user: userId })
)

unwrapResult(resultAction)

setTitle('')
setContent('')
setUserId('')
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}
}

在UI中调用thunk

首先从对应slice中import thunk至想要使用它的UI component。

thunk也是属于action creator的一种,我们需要dispatch,因此需要添加useDispatch hook。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)

const postStatus = useSelector(state => state.posts.status)

useEffect(() => {
if (postStatus === 'idle') {
// 这样可以避免re-render的时候也重新fetch data
dispatch(fetchPosts())
}
}, [postStatus, dispatch])

// omit rendering logic
}

createSlice对非createSlice API定义action的响应

There are times when a slice reducer needs to respond to other actions that weren’t defined as part of this slice’s reducers field. We can do that using the slice extraReducers field instead.

a slice reducer可能需要对thunk这个action creator产生的action或者createAction产生的action进行相应,为了实现这个目的在createSlice API中设立了一个extraReducers field。

extraReducers allows createSlice to respond to other action types besides the types it has generated. It’s particularly useful for working with actions produced by createAction and createAsyncThunk.

extraReducers field的action type

在extraReducers中定义的action createor产生的action不会被自动地添加到createSliceAPI的action field中去。extraReducers are meant to reference “external” actions, they will not have actions generated in slice.actions.

方式一:引号内action type

keys in extraReducer Object就是redux action type strings,类似于'counter/increment',因为key中含有斜杠/,所以要用单引号扩起来。

方式二: 计算属性

但是Redux Toolkit有一个性质就是如果调用actionCreator.toString()函数,他会自动返回action type。因此可以可以把action creator用ES6计算属性的方式传入extraReducer object field。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { increment } from '../features/counter/counterSlice'

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// slice-specific reducers here
},
extraReducers: {// 这个extraReducer本质也是一个object
[increment]: (state, action) => {
// normal reducer logic to update the posts slice
}
}
})

方式三: builder callback

将builder callback function传入extraReducers,而不是传入一个对象。builder callback function接受builder作为参数,对builder调用addCase就可以添加action creators。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { increment } from '../features/counter/counterSlice'

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// slice-specific reducers here
},
extraReducers: builder => {
builder.addCase('counter/decrement', (state, action) => {})
builder.addCase(increment, (state, action) => {})
}
})

extraReducers的action creator内容

也就是(state, action) => {}箭头函数里面的内容,应当都是对createSlice中的数据域进行操作。描述外部的某个action creator引起的某个action对本slice内部的数据产生了什么影响。

性能优化

React DevTools中的Prolifier可以协助查看用户在某个行为前后UI渲染的情况。需要先录制,然后做出行为,然后结束录制。就可以看到在该动作发生前后React各个组件的渲染行为。

extracting selectors from UI

在书写代码的过程中,一开始总是建议现在UI中使用useSelector选取希望获得的数据。因为虽然extracting selectors可以在更新slice中数据形式的时候简便一点,但这也意味着更多需要去维护的代码。

不是每一个UI都必须要写通用的selectors。Don’t feel like you need to write selectors for every single field of your state.

这种抽象适合这个sleectors在多个UI中被多次重复使用。Try starting without any selectors, and add some later when you find yourself looking up the same values in many parts of your application code

问题描述

我们一般认为在UI中访问redux store的数据就是要在UI中使用useSelector。这样处理的问题在于如果我们要从redux store中获取的那部分数据变化了,也就是说在slice中定义的数据形式变化了,相应地又要转到使用了该slice中的数据多个UI中去进行变动。

解决该问题的方式就是在slice文件中定义可以被export且可以被重复使用的selector functions。

One way to avoid this is to define reusable selector functions in the slice files, and have the components use those selectors to extract the data they need instead of repeating the selector logic in each component. That way, if we do change our state structure again, we only need to update the code in the slice file.

定义方式如下

1
2
// src/features/posts/postsSLice.js
export const selectAllPosts = state => state.posts

注意这里的state时redux store整体的state。

The state parameter for these selector functions is the root Redux state object, as it was for the inlined anonymous selectors we wrote directly inside of useSelector.

使用方式如下

1
2
3
4
5
6
7
// src/features/posts/PostList.js
import { selectAllPosts } from './postsSlice'

export const PostsList = () => {
const posts = useSelector(selectAllPosts)
// omit component contents
}

create “memoized” selectors

问题描述

useSelectors的特点就是只要app中一个行为发生了,他就会重新运行,只要他的return value是a new refernce value,那么它迫使组件重新渲染。

1
2
3
4
5
// UI component中
const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state)
return allPosts.filter(post => post.user === userId)
})

如果return value返回的对象总是无条件的是a new refernce value,比如filter函数,就算filter函数的内容都是一样的,reference都是不同,也就是说无论如何都会重新渲染一遍。

我们希望useSelector函数每重新运行一次,都保存先后两次select出来的value,如果发生了变化,在渲染,如果没有变化,就还是不渲染。这种想法就是memoize。

Memoizing Selector functions

Reselect is a library for creating memoized selector functions, and was specifically designed to be used with Redux. It has a createSelector function that generates memoized selectors that will only recalculate results when the inputs change.

想要使用createSelector,只需要先从Redux Toolkit中import它。

1
2
// slice中
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'

createSelector接受多个input selector function,以及一个output selector function作为参数。给createSelector传入的参数是input selector function的参数,而output selector function的参数是input selestor function的return value。如果传递给output selector function的value前后都没有变化,那么output selector function就不会re-run。

When we call selectPostsByUser(state, userId), createSelector will pass all of the arguments into each of our input selectors. Whatever those input selectors return becomes the arguments for the output selector.

1
2
3
4
5
6
7
8
9
export const selectAllPosts = state => state.posts.posts

export const selectPostById = (state, postId) =>
state.posts.posts.find(post => post.id === postId)

export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)

为什么要给input selector functions加括号呢?联系array destructing,这就相当于把input selector function运行并把返回值作为一组array,能够传递给下一个output selector function。

Solving cascade re-render

问题描述

React的渲染行为是级联的,只有父亲组件重新渲染了,那么父亲组件内部的所以儿子组件都会重新渲染。

React’s default behavior is that when a parent component renders, React will recursively render all child components inside of it!.

memo()

wrap the sub component in React.memo(), which will ensure that the component inside of it only re-renders if the props have actually changed.

1
2
3
4
let PostExcerpt = ({ post }) => {
// omit logic
}
PostExcerpt = React.memo(PostExcerpt)

条件渲染

子组件如果要进行条件渲染,那么就要从store中获取更多的信息,储存前后信息,并比较。

并且组件获取信息尽量不要基于内容本身获取信息,最好每一条信息都有一个识别符id。

数据ID与Normalized Data

之前一直都是手动维护一个ID field,用array.find来匹配希望获得的ID。其实这本质上就是一个lookup table,如果我们能通过id寻找到一条信息,而不是通过循环数组匹配来实现,那么这个过程就是normalization。能够实现这种模式的数据称为Normalized State Structure。

Normalized State Structure

具有以下特质的数据被称为normalized state structure。

  • We only have one copy of each particular piece of data in our state, so there’s no duplication
  • Data that has been normalized is kept in a lookup table, where the item IDs are the keys, and the items themselves are the values.
  • There may also be an array of all of the IDs for a particular item type

JavaScript Objects就可以被表示称lookup table,一个object中有两个field,一个是ids,另一个是entities。ids的组成是一个数组,数组中的每一个值又是entities中的key,也就是数据id对应到数据实体。

Redux Toolkit提供了一个操作normalized state structure的create EntityAdapter API,它将某个slice中的数据都按照{ ids: [], entities: {} } 的形式存放。

1
2
3
4
import {
createEntityAdapter
// omit other imports
} from '@reduxjs/toolkit'

and will only update that array if items are added / removed or the sorting order changes.

createEntityAdapter

数据对象创建

createEntityAdapter可以依据内容把ID array排序,它接收an option object that include a sortComparer function,通过比较两个entity的内容,把ID array中的id进行排序。

1
2
3
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
数据对象初始化

createEntityAdapter.getInitialState(),这个函数自动返回一个空的{ ids: [], entities: {} } 对象。还可以通过给getInitialState传入更多的参数,来给让返回的对象有更多的fields。

1
2
3
4
const initialState = postsAdapter.getInitialState({
status: 'idle',
error: null
})

数据对象选择

createEntityAdapter.getSelector(),把一个用于从redux store中选择某个特定slice的selector function作为参数传入该函数后,该函数能够自动返回名称总为selectAllselectById这样的selector function。

由于自动生成的selectors function总是固定的两个名字,所以利用ES6 array destructing的语法来为selector function重新命名。

The generated selector functions are always called selectAll and selectById, so we can use ES6 destructuring syntax to rename them as we export them and match the old selector names.

1
2
3
4
5
6
7
8
// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
// Pass in a selector that returns the posts slice of state
// Since the selectors are called with the root Redux state object, they need to know where to find our posts data in the Redux state, so we pass in a small selector that returns state.posts.
} = postsAdapter.getSelectors(state => state.posts)

数据对象更新

它返回一个包含了许多自动生成的reducer function的对象。这个对象中的reducer functions能够实现”add all these items”, “update one item”, or “remove multiple items”这一类的功能。

1
2
3
4
5
6
7
8
9
10
11
12
extraReducers: {
// omit other reducers

[fetchPosts.fulfilled]: (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
// Use the `upsertMany` reducer as a mutating update utility
postsAdapter.upsertMany(state, action.payload)
},
// Use the `addOne` reducer for the fulfilled case
[addNewPost.fulfilled]: postsAdapter.addOne
}
  • 本文标题:Redux异步数据流与性能优化
  • 本文作者:徐徐
  • 创建时间:2020-12-08 21:59:11
  • 本文链接:https://machacroissant.github.io/2020/12/08/redux-async-data-flow/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论