Previously, I wrote an article to briefly summarize my experience in learning Redux. I still recommend the official documentation because it is super clear.
However, when I was reading the official documentation before, I didn’t fully understand the middleware part and got confused towards the end. This time, I re-read the official documentation on middleware and asynchronous operations, took notes while reading, and finally understood the implementation principle of middleware. As usual, I will share my experience.
Official documentation (Chinese version, but this article has not been translated yet)
The great thing about the official documentation is that it not only teaches you how to use it, but also starts from scratch, so you know why middleware is in its current form.
#Main Content
After reading some tutorial articles, you think Redux is really great, so you start using Redux for your own product. But at this moment, you suddenly want to implement a feature: logging. You want to record every action and the changes to the store after executing the action. How to do it? Let’s start with the simplest method!
##First Attempt: Most Intuitive Method
Assuming that the code for dispatching actions is originally written like this:
store.dispatch(addTodo('Use Redux'));
We can directly change it to:
let action = addTodo('Use Redux');
console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getState());
##Second Attempt: Wrap it in a Function
But everyone knows that the first method cannot be done like this because there must be more than one place in the program that needs to do this. So what should we do next? Wrap it in a function.
function dispatchAndLog(store, action) {
console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getState());
}
dispatchAndLog(store, addTodo('Use Redux'));
However, in this way, you need to import this function every time you need to dispatch, is there a better way?
##Third Attempt: Monkeypatching
What is Monkeypatch
? You can Google it yourself. The idea is to replace something at runtime. Just like you can write in your chrome devtool:
console.log = function(text){
alert(text);
}
All messages that were originally displayed in the console will now be displayed using alert. How can we use it here?
//First, save the original because it will be used later
let next = store.dispatch;
//Override the current one
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action);
//Execute
next(action);
console.log('next state', store.getState());
return;
};
//The way to call it is the same as before
store.dispatch(addTodo('Use Redux'));
In this way, the original code does not need to be modified at all. You just need to replace store.dispatch
at the beginning of the program. It is easy and fun, but we soon encountered a new problem.
If I want an error reporting mechanism now, what should I do? When dispatching an error, I want to pass the error back to the server.
Hmm… Good question. We can separate these two tasks into two functions, like this:
function patchStoreToAddLogging(store) {
let next = store.dispatch;
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action);
next(action);
console.log('next state', store.getState());
return;
};
}
function patchStoreToAddCrashReporting(store) {
let next = store.dispatch;
store.dispatch = function dispatchAndReportErrors(action) {
try {
next(action);
} catch (err) {
console.error('Caught an exception!', err);
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
});
throw err;
}
};
}
patchStoreToAddLogging(store);
patchStoreToAddCrashReporting(store);
When I first read this, I was a bit confused about how this works. Won’t the second function overwrite the first one? But after reading it a few times, I finally understood the essence of it, which lies in the let next = store.dispatch;
line.
The behavior of the above code is roughly as follows:
- Execute the first function
patchStoreToAddLogging(store);
- The current dispatch function (the function that actually sends the action in Redux) is saved.
store.dispatch
is replaced withdispatchAndLog
, which records and calls the original dispatch function.- Execute the second function
patchStoreToAddCrashReporting(store);
- The current dispatch function (which is now
dispatchAndLog
) is saved. store.dispatch
is replaced withdispatchAndReportErrors
, which records and calls the original dispatch function.
Now, if we call dispatch(..)
, here’s what happens:
- Because it was replaced earlier,
dispatchAndReportErrors
is executed. next(action)
is executed, andnext
is the original dispatch function that was saved earlier.dispatchAndLog
is executed.- The action is recorded, and then
next(action)
is executed, andnext
is the original dispatch function. - The original dispatch function is executed, and after it’s done, it jumps back to the
dispatchAndLog
function. - It goes back to
dispatchAndLog
, prints out the changed state. - It goes back to
patchStoreToAddCrashReporting
, but since there are no errors, nothing happens. - It ends.
This way, we achieve the most important function of our middleware, which is to let the action go through layer after layer of middleware and finally reach the store. But this is still not good enough.
Fourth attempt: Hide Monkeypatching
Previously, we directly replaced store.dispatch
. What if we don’t replace it directly, but instead return a function? What will happen?
function logger(store) {
let next = store.dispatch;
// Previously:
// store.dispatch = function dispatchAndLog(action) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
}
Suppose we change our crashReporter
to this form as well. Then we can do this:
store.dispatcher = logger(store);
store.dispatcher = crashReporter(store);
We’re just extracting store.dispatcher
and putting it outside the function. But the advantage of doing this is that we can do this:
function applyMiddlewareByMonkeypatching(store, middlewares)
// Transform dispatch function with each middleware.
middlewares.forEach(middleware =>
store.dispatch = middleware(store)
);
}
applyMiddlewareByMonkeypatching(store, [logger, crashReporter]);
But this is still just extracting the monkeypatching part. In the next step, we need to completely remove the monkeypatching method.
Fifth Attempt: Removing Monkeypatching
Why do we need to override dispatch
? One important factor is that this is the only way to continuously call the previous dispatch
.
function logger(store) {
// This line is crucial to achieve chaining
let next = store.dispatch;
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
}
Without that important line, we cannot achieve the chaining effect. However, there is another way to achieve the same result. We can receive a next
parameter to achieve the same effect. The official documentation then quickly explains the most important part. Here, I will try to slow down the progress and explain more details, which is the concept of currying
.
Continuing from what we just talked about, we can receive a next
parameter, which will look like this:
function logger(store, next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
}
As you can see, it looks very similar to the previous one, except that the original next
is now passed in as a parameter. When we use it, it becomes:
let dispatch = store.dispatch;
dispatch = crashReporter(store, dispatch);
dispatch = logger(store, dispatch);
dispatch(addTodo('Use Redux'));
function logger(store, next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
}
function crashReport(store, next) {
return function dispatchAndReportErrors(action) {
try {
return next(action);
} catch (err) {
console.error('Caught an exception!', err);
throw err;
}
};
}
The difference is that the let next = store.dispatch;
inside the function is removed. Our middleware functions logger
and crashReport
are now cleaner. After changing it, we also need to change our original applyMiddleware
to make it conform to the new format:
function applyMiddleware(store, middlewares) {
let dispatch = store.dispatch;
middlewares.forEach(middleware =>
dispatch = middleware(store,dispatch)
);
return Object.assign({}, store, { dispatch });
}
// The way to call it is exactly the same, except that this function now returns a store
applyMiddleware(store, [logger, crashReporter]);
Next, let’s look at the official documentation and examples and see where they differ from what we just wrote. The first point is that we pass two parameters to logger
and crashReport
, but the official implementation only passes one. This is done using a technique called currying
. What is currying
? It is the process of breaking down a function with multiple parameters into many functions with only one parameter. You can understand it better by looking at the example:
function max(a,b){
return a>b?a:b;
}
max(1,5);
function maxCurrying(a){
return function inner(b){
return a>b?a:b;
}
}
maxCurrying(1)(5);
With this basic concept, we can also make the same changes to our logger
function:
// It can be compared with the original, and it is only one more layer of function wrapping
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
}
}
// ES6 syntax
const logger = store => next => action => {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
// Original
function logger(store, next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
};
}
When I first looked at the official documentation, the most confusing part for me was this section. Because I rarely used this kind of function that returns a function that returns a function, I was immediately confused. So I had to find a way to reduce the number of nested functions first, and then understand currying
before going back to understand the original code. For me, this way is easier, otherwise it would be too overwhelming.
applyMiddleware
can be changed to this:
function applyMiddleware(store, middlewares) {
let dispatch = store.dispatch;
middlewares.forEach(middleware =>
dispatch = middleware(store)(dispatch) // the difference is here
);
return Object.assign({}, store, { dispatch });
}
// original
function applyMiddleware(store, middlewares) {
let dispatch = store.dispatch;
middlewares.forEach(middleware =>
dispatch = middleware(store,dispatch) // the difference is here
);
return Object.assign({}, store, { dispatch });
}
In fact, at this point, it is already quite similar to the implementation of redux
itself. In fact, the way middleware is written is the way redux
requires it to be written. Finally, let’s take a look at how to use the official provided usage:
import { createStore, combineReducers, applyMiddleware } from 'redux';
// applyMiddleware takes createStore() and returns
// a function with a compatible API.
// Note that here, we used currying. We changed the two parameters to two functions.
// Originally, we passed in an array, but here we removed the array and just passed them in one by one.
let createStoreWithMiddleware = applyMiddleware(
logger,
crashReporter
)(createStore);
// Use it like you would use createStore()
let todoApp = combineReducers(reducers);
// Replace the original createStore with createStoreWithMiddleware
let store = createStoreWithMiddleware(todoApp);
Summary
When I was reading the official documentation, I could understand everything until it suddenly jumped to the currying
part. Because I was unfamiliar with this concept, I was confused. Later, I made up my mind to understand it, and after reading it again, I tried to understand what each line meant and how the process worked. After that, it became clearer.
This article is heavily based on the official example, and the code is copied directly, but with some minor changes explained here. For example, in the applyMiddleware
of the official example, there is actually:
middlewares = middlewares.slice();
middlewares.reverse();
reverse()
is because of the order of execution, and slice()
is to copy the array. But I don’t know why it needs to be copied, maybe to avoid changing the original parameters?
In short, I hope this article can help some beginners who are confused like me. I still recommend everyone to read the official documentation. If there are any mistakes in my writing, please leave a comment or send me an email. Thank you.
Reference:
http://camsong.github.io/redux-in-chinese/docs/advanced/Middleware.html
Comments