The book is finally due, so today I have a little spare time to dedicate an article about JavaScript language style, the protagonist is functional declarative development. We took a simple, object-oriented EventEmitter system and transformed it step by step into a functional style. Examples are given to illustrate the excellent features of functional expressions.
Flexible JavaScript and its multiparadigm
I believe the concept of “functional” is familiar to many front-end developers: We know that JavaScript is a very flexible, multiparadigm language. This article will show the switch between imperative and declarative styles in JavaScript. The purpose of this article is to give readers an understanding of the characteristics of the two different language patterns. Then in the daily development of reasonable choice, play to the maximum power of JavaScript.
For the sake of illustration, we start with a typical event publish and subscribe system and complete the functional style transformation step by step. The event publishing and subscription system, also known as Pub/Sub mode, adheres to the idea of event-driven and realizes the design of “high cohesion and low coupling”.
If you are not familiar with this model, you are advised to read my original article: Exploring the source code of Node.js event mechanism to build your own event publishing and subscription system. This article starts from node.js event module source code, analyzes the implementation of the event publishing and subscription system, and based on ES Next syntax, to achieve an imperative, object-oriented event publishing and responder. This article will not expand too much on this basic content.
Typical EventEmitter and retrofit challenges
On the basis of understanding the idea of an event publish and subscribe system implementation, let’s look at a simple and typical basic implementation:
class EventManager { construct (eventMap = new Map()) { this.eventMap = eventMap; } addEventListener (event, handler) { if (this.eventMap.has(event)) { this.eventMap.set(event, this.eventMap.get(event).concat([handler])); } else { this.eventMap.set(event, [handler]); } } dispatchEvent (event) { if (this.eventMap.has(event)) { const handlers = this.eventMap.get(event); for (const i in handlers) { handlers[i](); }}}}Copy the code
The above code implements an EventManager class: we maintain an eventMap of the type Map, and we maintain all the callbacks for different events.
- The addEventListener method stores the callback function for the specified event.
- The dispatchEvent method executes its callback functions one by one on the specified trigger events.
At the consumption level:
const em = new EventManager();
em.addEventListner('hello', function() {
console.log('hi');
});
em.dispatchEvent('hello'); // hi
Copy the code
It’s all easier to understand. Here are our challenges:
- Convert the above 20 lines of imperative code into 7 lines of 2 expressions declarative code;
- No longer use {… } and if judgment conditions;
- Pure function is used to avoid side effects.
- Use unary functions, that is, only one parameter is required in the function equation;
- Make functions composable;
- Code implementation should be clean, elegant, and low coupling.
Let’s start with a comparison of the final results:
We will go through this process step by step in a moment.
Step1: use functions instead of classes
Based on the above challenges, addEventListener and dispatchEvent no longer appear as methods of the EventManager class, but as two separate functions, with eventMap as the variable:
const eventMap = new Map(); function addEventListener (event, handler) { if (eventMap.has(event)) { eventMap.set(event, eventMap.get(event).concat([handler])); } else { eventMap.set(event, [handler]); } } function dispatchEvent (event) { if (eventMap.has(event)) { const handlers = this.eventMap.get(event); for (const i in handlers) { handlers[i](); }}}Copy the code
For modularity purposes, we can export these two functions:
export default {addEventListener, dispatchEvent};
Copy the code
Also use import to introduce dependencies, noting that the import implementation is singleton:
import * as EM from './event-manager.js';
EM.dispatchEvent('event');
Copy the code
Because the module is a singleton case, the internal variable eventMap is shared when different files are introduced, exactly as expected.
Step2: use the arrow function
The arrow function is different from the traditional function expression, and more suitable for the functional “taste” :
const eventMap = new Map(); const addEventListener = (event, handler) => { if (eventMap.has(event)) { eventMap.set(event, eventMap.get(event).concat([handler])); } else { eventMap.set(event, [handler]); } } const dispatchEvent = event => { if (eventMap.has(event)) { const handlers = eventMap.get(event); for (const i in handlers) { handlers[i](); }}}Copy the code
Pay special attention to the arrow function’s binding of this. Of course, the arrow function itself is also called a lambda function, which is “functional” in its name.
Step3: remove the side effects and increase the return value
Instead of changing eventMap, we should return a new maptype variable and change the parameters of the addEventListener and dispatchEvent methods. Added “previous state” eventMap to push the new eventMap:
const addEventListener = (event, handler, eventMap) => {
if (eventMap.has(event)) {
return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
} else {
return new Map(eventMap).set(event, [handler]);
}
}
const dispatchEvent = (event, eventMap) => {
if (eventMap.has(event)) {
const handlers = eventMap.get(event);
for (const i in handlers) {
handlers[i]();
}
}
return eventMap;
}
Copy the code
Yes, this process is very similar to the Reducer function in Redux. Keeping the function pure is one of the most important aspects of the functional concept.
Step4: remove the declarative style for loop
Next, we use forEach instead of the for loop:
const addEventListener = (event, handler, eventMap) => {
if (eventMap.has(event)) {
return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
} else {
return new Map(eventMap).set(event, [handler]);
}
}
const dispatchEvent = (event, eventMap) => {
if (eventMap.has(event)) {
eventMap.get(event).forEach(a => a());
}
return eventMap;
}
Copy the code
Step5: Apply binary operators
We use the | | and && to make your code more concise and straightforward:
const addEventListener = (event, handler, eventMap) => {
if (eventMap.has(event)) {
return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
} else {
return new Map(eventMap).set(event, [handler]);
}
}
const dispatchEvent = (event, eventMap) => {
return (
eventMap.has(event) &&
eventMap.get(event).forEach(a => a())
) || event;
}
Copy the code
It is typical to pay special attention to the expression of a return statement:
return (
eventMap.has(event) &&
eventMap.get(event).forEach(a => a())
) || event;
Copy the code
Step6: Use the ternary operator instead of if
If this kind of imperative “ugly” can not exist, we use the ternary operator more intuitive and concise:
const addEventListener = (event, handler, eventMap) => {
return eventMap.has(event) ?
new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
new Map(eventMap).set(event, [handler]);
}
const dispatchEvent = (event, eventMap) => {
return (
eventMap.has(event) &&
eventMap.get(event).forEach(a => a())
) || event;
}
Copy the code
Step7: remove the curly braces {… }
Since the arrow function always returns the value of the expression, we no longer need any {… } :
const addEventListener = (event, handler, eventMap) =>
eventMap.has(event) ?
new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
new Map(eventMap).set(event, [handler]);
const dispatchEvent = (event, eventMap) =>
(eventMap.has(event) && eventMap.get(event).forEach(a => a())) || event;
Copy the code
Step8: complete currying
The last step is to implement a currying operation, which will make our function unary (take only one argument), using higher-order function. (a, b, c) => b => C:
const addEventListener = handler => event => eventMap =>
eventMap.has(event) ?
new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
new Map(eventMap).set(event, [handler]);
const dispatchEvent = event => eventMap =>
(eventMap.has(event) && eventMap.get(event).forEach (a => a())) || event;
Copy the code
If readers have some difficulty in understanding this, it is recommended to supplement the knowledge of currying first, and will not be expanded here.
Of course, we need to consider the order of the parameters. Let’s digest it by example.
Use of currying:
const log = x => console.log (x) || x;
const myEventMap1 = addEventListener(() => log('hi'))('hello')(new Map());
dispatchEvent('hello')(myEventMap1); // hi
Copy the code
Partial use:
const log = x => console.log (x) || x;
let myEventMap2 = new Map();
const onHello = handler => myEventMap2 = addEventListener(handler)('hello')(myEventMap2);
const hello = () => dispatchEvent('hello')(myEventMap2);
onHello(() => log('hi'));
hello(); // hi
Copy the code
Readers familiar with Python will probably have a better understanding of partial. In simple terms, a partial application of a function can be interpreted as:
When a function is executed, it is called with all the necessary arguments. However, sometimes parameters can be known in advance before the function is called. In this case, a function has one or more arguments available in advance so that the function can be called with fewer arguments.
Such as:
const sum = a => b => a + b;
const sumTen = sum(10)
sumTen(20)
// 30
Copy the code
Is a kind of embodiment.
Back in our scenario, for the onHello function, the argument is the callback when the Hello event is triggered. Here myEventMap2 and hello events are pre-set. For the Hello function, all it needs to do is launch the Hello event.
Combination use:
const log = x => console.log (x) || x; const compose = (... fns) => fns.reduce((f, g) => (... args) => f(g(... args))); const addEventListeners = compose( log, addEventListener(() => log('hey'))('hello'), addEventListener(() => log('hi'))('hello') ); const myEventMap3 = addEventListeners(new Map()); // myEventMap3 dispatchEvent('hello')(myEventMap3); // hi heyCopy the code
Pay special attention to the compose method here. Readers familiar with Redux who have read the Redux source code will be familiar with Compose. For compose, we implemented the two callback functions for the Hello event, as well as the log function.
Compose (f, g, h) is equivalent to (... args) => f(g(h(... args))).Copy the code
For more information about the compose method, and the different ways to implement it, check out the author: Lucas HC. I’ll write an article on why Redux’s implementation of Compose is somewhat obscure, as well as a more intuitive approach.
conclusion
The functional concept may not be very friendly to beginners. You can stop reading at any time in each of the eight steps, depending on your familiarity and preference. And we welcome discussion.
This is an paraphrase of Martin Novak’s new article.
As @Yan Haijing said:
The result of the function is that, in the end, you don’t understand it…
Commercial Break: If you’re interested in front-end development, especially the React stack: My new book may have something you want to see. Follow author Lucas HC, there will be a book delivery event for the book release.
Happy Coding!
PS: Author Github warehouse and Zhihu Q&A link welcome all forms of communication.