This lesson is taught by Teacher Moonlight, packed with valuable content, including object-oriented design, component encapsulation, and higher-order functions (throttling, debouncing, batching, iterability).
Key Content of This Lesson#
Principles of Writing Good JavaScript#
Each Has Its Own Responsibilities#
For example: writing a piece of JS to control a webpage to support both light and dark modes. How would you do it?
My first reaction: write a dark mode class and switch it during the toggle button event. This is also the second version discussed in the course materials.
- First version directly switches styles, which is not ideal but works
const btn = document.getElementById('modeBtn');
btn.addEventListener('click', (e) => {
const body = document.body;
if(e.target.innerHTML === '☀️') {
body.style.backgroundColor = 'black';
body.style.color = 'white';
e.target.innerHTML = '🌙';
} else {
body.style.backgroundColor = 'white';
body.style.color = 'black';
e.target.innerHTML = '☀️';
}
});
- Second version encapsulates the dark mode class
const btn = document.getElementById('modeBtn');
btn.addEventListener('click', (e) => {
const body = document.body;
if(body.className !== 'night') {
body.className = 'night';
} else {
body.className = '';
}
});
-
Third version: since it is a purely display behavior, it can be fully implemented with HTML and CSS
Use a toggle as a type of
checkbox
control, with an id ofmodeCheckBox
, and use thelabel
tag'sfor
attribute to associate it with this control, then simply hide the checkbox to achieve the toggle mode."
As long as you don't write code, there won't be any bugs", this is also a reflection of each having its own responsibilities.
Summary: Avoid unnecessary direct style manipulation by JS; use classes to represent states, while seeking zero-JS solutions for purely display-related interactions. Version 2 also has its advantages, such as adaptability, which may not be as good as version 3.
Component Encapsulation#
A component refers to a unit extracted from a web page that contains templates (HTML), functionality (JS), and styles (CSS). Good components possess encapsulation, correctness, extensibility, and reusability. Although there are many excellent components available now, we often do not need to design one ourselves, but we should try to understand their implementations.
For example: how to implement a carousel for an e-commerce website using native JS?
-
Structure: Unordered list in HTML (
<ul>
)- A carousel is a typical list structure, which can be implemented using the unordered list
<ul>
element, with each image placed in an<li>
tag.
- A carousel is a typical list structure, which can be implemented using the unordered list
-
Presentation: CSS Absolute Positioning
- Use CSS absolute positioning to overlap images in one position
- Use modifiers for state switching
- selected
- Carousel switching animation is implemented using CSS
transition
-
Behavior: JS
- API design should ensure atomic operations, single responsibilities, and meet flexibility requirements
- ps: Atomic operations refer to a series of operations that cannot be interrupted, such as primitives like wait, read in operating systems.
- Encapsulate some events: getSelectedItem(), getSelectedItemIndex(), slideTo(), slideNext(), slidePrevious()…
- Further: control flow, use custom events for decoupling.
- API design should ensure atomic operations, single responsibilities, and meet flexibility requirements
Summary: Component encapsulation needs to pay attention to whether its structural design, presentation effects, and behavior design (API, Event, etc.) meet standards.
Reflection: How to improve this carousel?
Refactor 1: Pluginization, Decoupling#
-
Extract control elements into individual plugins (left/right arrows, four small dots below), etc.
-
Establish connections between plugins and components through dependency injection.
What are the benefits? The constructor of the component only registers the components one by one, and when reusing in the future, unnecessary components can simply be commented out from the constructor without needing to care about others.
Further expansion?
Refactor 2: Templateization#
Template the HTML so that a single <div class='slider'></div>
can achieve image carousel functionality, modifying the controller's constructor to pass in the image list.
Refactor 3: Abstraction#
Abstract a generic component model into a component class (Component), with other component classes inheriting this class and implementing its render method.
class Component{
constructor(id, opts = {name, data: []}) {
this.container = document.getElementById(id);
this.options = opts;
this.container.innerHTML = this.render(opts.data);
}
registerPlugins(...plugins) {
plugins.forEach( plugin => {
const pluginContainer = document.createElement( 'div');
pluginContainer.className = `${name}__plugin`;
pluginContainer.innerHTML = plugin.render(this.options.data);
this.container.appendChild(pluginContainer);
plugin.action(this);
});
}
render(data) {
/* abstract */
return ''
}
}
Summary:
- Principles of component design—encapsulation, correctness, extensibility, and reusability
- Implementation steps: structural design, presentation effects, behavior design
- Three refactors
- Pluginization
- Templateization
- Abstraction
- Improvements: CSS templateization, state synchronization and message communication between parent and child components, etc.
Process Abstraction#
-
Methods for handling local detail control
-
Basic application of functional programming concepts
Application: Limiting Operation Frequency#
- Some asynchronous interactions
- One-time HTTP requests
There is a piece of code that removes a node after a 2-second delay on each click, but if the user clicks several times before the node is completely removed, it will throw an error.
const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button)=>{
button.addEventListener('click', (evt) => {
const target = evt.target;
target.parentNode.className = 'completed';
setTimeout(()=>{
list.removeChild(target.parentNode);
},2000);
})
});
This limitation on the number of operations can be abstracted into a higher-order function.
function once(fn) {
return function(...args) {
if(fn) {
const ret = fn.apply(this, args);
fn = null;
return ret;
}
}
}
const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button)=>{
button.addEventListener('click', once((evt) => {
const target = evt.target;
target.parentNode.className = 'completed';
setTimeout(()=>{
list.removeChild(target.parentNode);
},2000);
}))
});
As shown in the code, this once
function accepts a function and returns another function that checks if the accepted function is null; if not, it executes the function and returns its result, and if it is null, it returns a function that does nothing. The click event registered is actually the function returned by once
, so no matter how many times you click, it won't throw an error.
ps: What a wonderful application example!
To allow the "execute only once" requirement to cover different event handlers, this requirement is separated out, and this process is called process abstraction.
Higher-Order Functions#
- Functions as parameters
- Functions as return values
- Commonly used as function decorators
function HOF(fn) {
return function(...args) {
return fn.apply(this, args);
}
}
Common Higher-Order Functions#
Once: Execute Only Once#
As mentioned earlier, this will not be elaborated on again.
Throttle#
Add an interval time to a function, calling it once every interval, saving its demand. For example, if an event occurs continuously (like mouse hover), it will trigger the event function at a very fast speed. In this case, adding a throttle function can prevent crashes and save bandwidth.
function throttle(fn, time = 500) {
let timer;
return function(...args) {
if(!timer) {
fn.apply(this, args);
timer = setTimeout(() => {
timer = null;
}, time);
}
}
}
btn.onclick = throttle(function(e){
/* Event handling */
circle.innerHTML = parseInt(circle.innerHTML)+1;
circle.className = 'fade';
setTimeout(() => circle.className = '', 250);
});
The original function is wrapped; if there is no timer, a timer is registered, which will be canceled after 500ms. During this 500ms, the timer still exists, so the function will not be executed (or will execute an empty function). After 500ms, the timer is canceled, and the function can be called and executed.
Debounce#
In the throttling example above, the function will not execute while the timer exists, whereas debouncing clears the timer at the start of each event and sets the timer to dur
. When the event is called for dur
time without new events being called (for example, after hovering the mouse for a while), the function can be called and executed.
function debounce(fn, dur) {
dur = dur || 100; // If dur does not exist, set dur to 100ms
var timer;
return function() {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, arguments);
}, dur);
}
}
Consumer#
This turns a function into an asynchronous operation similar to setTimeout
, where many calls to an event are collected into a list and executed at set intervals. Let's look at the code:
function consumer(fn, time) {
let tasks = [],
timer;
return function (...args) {
tasks.push(fn.bind(this, ...args));
if(timer == null) {
timer = setInterval(() => {
tasks.shift().call(this);
if(tasks.length <= 0) {
clearInterval(timer);
timer = null;
}
}, time);
}
}
}
btn.onclick = consumer((evt) => {
/*
* Event handling: when the button is clicked, it executes this
* continuously displaying +count and fading out after 500ms,
* while rapid clicks store these click events in a list and
* execute them every 800ms (otherwise the previous +count
* has not disappeared yet).
*/
let t = parseInt(count.innerHTML.slice(1)) + 1;
count.className = 'hit';
let r = t * 7 % 256,
g = t * 17 % 128,
b = t * 31 % 128;
count.style.color = `rgb(${r}, ${g}, ${b})`.trim();
setTimeout(() => {
count.className = 'hide';
}, 500);
}, 800);
To understand the function's principles, we need to discuss the bind
function, shift
function, and call
:
bind()
method creates a new function, and whenbind()
is called, this new function'sthis
is set to the first argument ofbind()
, while the remaining arguments are used as parameters for the new function when called.
shift()
method removes the first element from an array and returns its value. This method changes the length of the array. The opposite of this isunshift()
, which inserts the first element.A similar pair of methods are
pop()
andpush()
, which operate on the last element of the array.
call()
method calls a function with a specifiedthis
value and one or more arguments.
Thus, it is clear that this function's purpose is to place each function ready to be called into the tasks
list. If the timer is empty, it sets a timer to execute content periodically execute tasks from the queue, and if all tasks are cleared (currently no tasks), it clears the timer
. If the timer is not empty, it does nothing (but the function is still placed in the tasks
list).
Iterative#
This turns a function into an iterable one, typically used when a function needs to perform batch operations on a group of objects. For example, setting colors in bulk, as shown in the code:
const isIterable = obj => obj != null && typeof obj[Symbol.iterator] === 'function';
function iterative(fn) {
return function(subject, ...rest) {
if(isIterable(subject)) {
const ret = [];
for(let obj of subject) {
ret.push(fn.apply(this, [obj, ...rest]));
}
return ret;
}
return fn.apply(this, [subject, ...rest]);
}
}
const setColor = iterative((el, color) => {
el.style.color = color;
})
const els = document.querySelectorAll('li:nth-child(2n+1)');
setColor(els, 'red');
Toggle#
Switching states can also be encapsulated into a higher-order function, allowing for as many states as needed to be added inside.
Example:
function toggle(...actions) {
return function (...args) {
let action = actions.shift();
action.push(action);
return action.apply(this, args);
}
}
// Can handle multiple states!
switcher.onclick = toggle(
evt => evt.target.className = 'off',
evt => evt.target.className = 'on'
);
Reflection#
Why use higher-order functions?
Understanding a concept: Pure functions are those whose return results depend only on their parameters and have no side effects during execution.
This means that pure functions are very reliable and do not affect the outside world.
- Convenient for unit testing!
- Reduces the number of non-pure functions in the system, thereby increasing system reliability.
Other Reflections#
- Imperative vs. declarative, there is no superiority.
- Process abstraction / HOF / Decorators
- Imperative / Declarative
- Balancing code style, efficiency, and quality.
- Weighing based on the scenario.
Summary and Thoughts#
Incredible!!
After watching this lesson, I gained a lot. Implementing a truly meaningful component requires so many steps, and JavaScript can achieve such object-oriented design. Combining it with design patterns learned in C++/Java, I found many commonalities. A component can be subdivided into many subcomponents. The subsequent higher-order functions are even more of a knowledge blind spot; I never knew JavaScript could implement these methods.
Most of the content quoted in this article comes from Teacher Moonlight's class and MDN.